Skip to main content

test_doubles_usage/
test_doubles_usage.rs

1//! Test doubles (spy/stub/mock) for isolating dependencies.
2//!
3//! Demonstrates how to use test_doubles to verify interactions
4//! between Lua components without relying on real implementations.
5
6fn main() {
7    let summary = mlua_lspec::run_tests(
8        r#"
9        local describe, it, expect = lust.describe, lust.it, lust.expect
10
11        -- ================================================================
12        -- System under test: an event dispatcher
13        -- ================================================================
14        local function create_dispatcher()
15            local handlers = {}
16            return {
17                on = function(self, event, handler)
18                    handlers[event] = handlers[event] or {}
19                    table.insert(handlers[event], handler)
20                end,
21                emit = function(self, event, data)
22                    if handlers[event] then
23                        for _, h in ipairs(handlers[event]) do
24                            h(data)
25                        end
26                    end
27                end,
28                handler_count = function(self, event)
29                    return handlers[event] and #handlers[event] or 0
30                end,
31            }
32        end
33
34        -- ================================================================
35        -- Tests
36        -- ================================================================
37
38        describe('event dispatcher', function()
39
40            describe('spy: verifying handler calls', function()
41                it('calls registered handler on emit', function()
42                    local d = create_dispatcher()
43                    local handler = test_doubles.spy(function() end)
44
45                    d:on("click", handler)
46                    d:emit("click", { x = 10, y = 20 })
47
48                    expect(handler:call_count()).to.equal(1)
49                end)
50
51                it('passes event data to handler', function()
52                    local d = create_dispatcher()
53                    local handler = test_doubles.spy(function() end)
54
55                    d:on("click", handler)
56                    d:emit("click", { x = 10, y = 20 })
57
58                    -- was_called_with compares primitives; for tables, use call_args
59                    local args = handler:call_args(1)
60                    expect(args[1].x).to.equal(10)
61                    expect(args[1].y).to.equal(20)
62                end)
63
64                it('calls multiple handlers in order', function()
65                    local d = create_dispatcher()
66                    local order = {}
67                    local h1 = test_doubles.spy(function() table.insert(order, "first") end)
68                    local h2 = test_doubles.spy(function() table.insert(order, "second") end)
69
70                    d:on("click", h1)
71                    d:on("click", h2)
72                    d:emit("click", {})
73
74                    expect(h1:call_count()).to.equal(1)
75                    expect(h2:call_count()).to.equal(1)
76                    expect(order).to.equal({"first", "second"})
77                end)
78
79                it('does not call handler for different event', function()
80                    local d = create_dispatcher()
81                    local handler = test_doubles.spy(function() end)
82
83                    d:on("click", handler)
84                    d:emit("hover", {})
85
86                    expect(handler:call_count()).to.equal(0)
87                end)
88            end)
89
90            describe('stub: faking external services', function()
91                -- Simulate a module that calls an external API
92                local function fetch_user(api_client, user_id)
93                    local response = api_client.get("/users/" .. user_id)
94                    if response.status == 200 then
95                        return response.body
96                    end
97                    return nil, "not found"
98                end
99
100                it('returns user data on success', function()
101                    local api = { get = test_doubles.stub() }
102                    api.get:returns({ status = 200, body = { name = "Alice", id = 1 } })
103
104                    local user = fetch_user(api, 1)
105
106                    expect(user).to.exist()
107                    expect(user.name).to.equal("Alice")
108                    expect(api.get:call_count()).to.equal(1)
109                end)
110
111                it('returns nil on not found', function()
112                    local api = { get = test_doubles.stub() }
113                    api.get:returns({ status = 404, body = nil })
114
115                    local user, err = fetch_user(api, 999)
116
117                    expect(user).to_not.exist()
118                    expect(err).to.equal("not found")
119                end)
120            end)
121
122            describe('spy_on: intercepting existing methods', function()
123                it('records calls to existing method', function()
124                    local logger = {
125                        messages = {},
126                        log = function(self, msg)
127                            table.insert(self.messages, msg)
128                        end,
129                    }
130
131                    local spy = test_doubles.spy_on(logger, "log")
132
133                    -- Call through the original object
134                    logger.log(logger, "hello")
135                    logger.log(logger, "world")
136
137                    -- Spy recorded the calls
138                    expect(spy:call_count()).to.equal(2)
139
140                    -- Original still worked (call-through)
141                    expect(#logger.messages).to.equal(2)
142                end)
143
144                it('reverts to original after test', function()
145                    local calculator = {
146                        add = function(a, b) return a + b end,
147                    }
148
149                    local spy = test_doubles.spy_on(calculator, "add")
150                    calculator.add(1, 2)  -- recorded by spy
151                    expect(spy:call_count()).to.equal(1)
152
153                    spy:revert()
154
155                    -- Now it's the original function again
156                    local result = calculator.add(10, 20)
157                    expect(result).to.equal(30)
158                end)
159            end)
160
161            describe('spy as stub: switching behavior', function()
162                it('spy can be converted to stub mid-test', function()
163                    local counter = 0
164                    local s = test_doubles.spy(function()
165                        counter = counter + 1
166                        return counter
167                    end)
168
169                    -- Initially calls through
170                    expect(s()).to.equal(1)
171                    expect(s()).to.equal(2)
172
173                    -- Switch to stub behavior
174                    s:returns(999)
175
176                    -- Now returns fixed value
177                    expect(s()).to.equal(999)
178                    expect(s()).to.equal(999)
179
180                    -- All 4 calls recorded
181                    expect(s:call_count()).to.equal(4)
182                end)
183            end)
184        end)
185    "#,
186        "@test_doubles.lua",
187    )
188    .expect("test execution failed");
189
190    println!(
191        "{} passed, {} failed out of {} tests",
192        summary.passed, summary.failed, summary.total
193    );
194    for test in &summary.tests {
195        let icon = if test.passed { "PASS" } else { "FAIL" };
196        println!("  [{icon}] {}: {}", test.suite, test.name);
197        if let Some(ref err) = test.error {
198            println!("         {err}");
199        }
200    }
201
202    assert_eq!(summary.failed, 0, "all tests should pass");
203}