smartest 0.1.0.alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/DEVELOPMENT.md +774 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +518 -0
- data/Rakefile +12 -0
- data/SMARTEST_DESIGN.md +1137 -0
- data/exe/smartest +63 -0
- data/lib/smartest/autorun.rb +8 -0
- data/lib/smartest/dsl.rb +22 -0
- data/lib/smartest/errors.rb +52 -0
- data/lib/smartest/execution_context.rb +8 -0
- data/lib/smartest/expectation_target.rb +21 -0
- data/lib/smartest/expectations.rb +9 -0
- data/lib/smartest/fixture.rb +78 -0
- data/lib/smartest/fixture_class_registry.rb +27 -0
- data/lib/smartest/fixture_definition.rb +31 -0
- data/lib/smartest/fixture_set.rb +119 -0
- data/lib/smartest/init_generator.rb +70 -0
- data/lib/smartest/matchers.rb +109 -0
- data/lib/smartest/parameter_extractor.rb +51 -0
- data/lib/smartest/reporter.rb +91 -0
- data/lib/smartest/runner.rb +80 -0
- data/lib/smartest/suite.rb +12 -0
- data/lib/smartest/test_case.rb +18 -0
- data/lib/smartest/test_registry.rb +25 -0
- data/lib/smartest/test_result.rb +43 -0
- data/lib/smartest/version.rb +5 -0
- data/lib/smartest.rb +59 -0
- data/smartest/smartest_test.rb +634 -0
- data/smartest.gemspec +48 -0
- metadata +95 -0
data/SMARTEST_DESIGN.md
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
# Smartest Design
|
|
2
|
+
|
|
3
|
+
This document records the design of Smartest.
|
|
4
|
+
|
|
5
|
+
Smartest is a Ruby test runner inspired by pytest, Vitest, and Playwright Test, but with an API that should feel natural in Ruby.
|
|
6
|
+
|
|
7
|
+
## Design summary
|
|
8
|
+
|
|
9
|
+
Smartest provides:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
test("factorial") do
|
|
13
|
+
expect(1 * 2 * 3).to eq(6)
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Fixture usage:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
test("GET /me") do |logged_in_client:|
|
|
21
|
+
response = logged_in_client.get("/me")
|
|
22
|
+
|
|
23
|
+
expect(response.status).to eq(200)
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Fixture definitions:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class WebFixture < Smartest::Fixture
|
|
31
|
+
fixture :server do
|
|
32
|
+
server = TestServer.start
|
|
33
|
+
cleanup { server.stop }
|
|
34
|
+
|
|
35
|
+
server.wait_until_ready!
|
|
36
|
+
server
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
fixture :client do |server:|
|
|
40
|
+
Client.new(base_url: server.url)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
fixture :user do
|
|
44
|
+
User.create!(email: "alice@example.com")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
fixture :logged_in_client do |client:, user:|
|
|
48
|
+
client.login(user)
|
|
49
|
+
client
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
use_fixture WebFixture
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The core decision is:
|
|
57
|
+
|
|
58
|
+
> Fixture dependencies and test fixture usage are expressed as required keyword arguments.
|
|
59
|
+
|
|
60
|
+
## Why keyword arguments?
|
|
61
|
+
|
|
62
|
+
Several forms were considered.
|
|
63
|
+
|
|
64
|
+
### Positional test fixture injection
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
test("GET /me") do |logged_in_client|
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This is concise and close to pytest.
|
|
72
|
+
|
|
73
|
+
However, in Ruby it reads like an ordinary block argument. It is not obvious that the value is injected by name.
|
|
74
|
+
|
|
75
|
+
It also creates ambiguity:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
test("example") do |user, article|
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Are `user` and `article` matched by position or by name?
|
|
83
|
+
|
|
84
|
+
Smartest avoids this ambiguity.
|
|
85
|
+
|
|
86
|
+
### Keyword test fixture injection
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
test("GET /me") do |logged_in_client:|
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This reads as named input to the test.
|
|
94
|
+
|
|
95
|
+
Ruby exposes the name clearly through `Proc#parameters`:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
proc { |logged_in_client:| }.parameters
|
|
99
|
+
# => [[:keyreq, :logged_in_client]]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This gives Smartest a stable way to discover requested fixtures.
|
|
103
|
+
|
|
104
|
+
### `with:` fixture dependencies
|
|
105
|
+
|
|
106
|
+
Considered:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
fixture :client, with: [:server] do |server|
|
|
110
|
+
Client.new(base_url: server.url)
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This makes dependency declaration explicit, but it duplicates information.
|
|
115
|
+
|
|
116
|
+
The dependency appears in two places:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
with: [:server]
|
|
120
|
+
do |server|
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This can drift:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
fixture :client, with: [:user, :server] do |server, user|
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Keyword arguments avoid this:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
fixture :client do |server:, user:|
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The names are the API. The order does not matter.
|
|
138
|
+
|
|
139
|
+
### Implicit method-call fixture dependencies
|
|
140
|
+
|
|
141
|
+
Considered:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
fixture :client do
|
|
145
|
+
Client.new(base_url: server.url)
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
This is very Ruby-like, but dependency discovery requires executing code.
|
|
150
|
+
|
|
151
|
+
It makes static dependency analysis difficult.
|
|
152
|
+
|
|
153
|
+
It also makes circular dependency detection later and less clear.
|
|
154
|
+
|
|
155
|
+
Smartest prefers:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
fixture :client do |server:|
|
|
159
|
+
Client.new(base_url: server.url)
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Dependencies are explicit and discoverable before fixture execution.
|
|
164
|
+
|
|
165
|
+
## Why not `resource` for setup/teardown?
|
|
166
|
+
|
|
167
|
+
Playwright Test-style fixtures often use a `use` callback:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
fixture :server do |use|
|
|
171
|
+
server = TestServer.start
|
|
172
|
+
|
|
173
|
+
use.call(server)
|
|
174
|
+
ensure
|
|
175
|
+
server&.stop
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
This is powerful because the fixture surrounds the test body.
|
|
180
|
+
|
|
181
|
+
However, it complicates the execution model.
|
|
182
|
+
|
|
183
|
+
To support this fully, Smartest would need to build an around-chain:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
server setup
|
|
187
|
+
temp_dir setup
|
|
188
|
+
test body
|
|
189
|
+
temp_dir teardown
|
|
190
|
+
server teardown
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
This is especially complex when fixtures depend on other fixtures.
|
|
194
|
+
|
|
195
|
+
Smartest instead chooses `cleanup` for the MVP:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
fixture :server do
|
|
199
|
+
server = TestServer.start
|
|
200
|
+
cleanup { server.stop }
|
|
201
|
+
|
|
202
|
+
server.wait_until_ready!
|
|
203
|
+
server
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This has several advantages:
|
|
208
|
+
|
|
209
|
+
- fixtures always return values
|
|
210
|
+
- teardown is optional
|
|
211
|
+
- teardown is local to the fixture that owns the resource
|
|
212
|
+
- implementation is simple
|
|
213
|
+
- fixture dependencies remain ordinary recursive resolution
|
|
214
|
+
- cleanup runs in `ensure`
|
|
215
|
+
|
|
216
|
+
Not every fixture needs teardown, so teardown should not shape the entire fixture API.
|
|
217
|
+
|
|
218
|
+
## Fixture model
|
|
219
|
+
|
|
220
|
+
A fixture is a named value provider.
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
fixture :user do
|
|
224
|
+
User.create!(name: "Alice")
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
A fixture may depend on other fixtures.
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
fixture :article do |user:|
|
|
232
|
+
Article.create!(author: user)
|
|
233
|
+
end
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
A fixture may register cleanup.
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
fixture :temp_dir do
|
|
240
|
+
dir = Dir.mktmpdir
|
|
241
|
+
cleanup { FileUtils.rm_rf(dir) }
|
|
242
|
+
dir
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
By default, a fixture value is cached per test.
|
|
247
|
+
|
|
248
|
+
Within one test, resolving the same test-scoped fixture multiple times returns
|
|
249
|
+
the same value.
|
|
250
|
+
|
|
251
|
+
Across tests, test-scoped fixtures are re-created. `suite_fixture` values are
|
|
252
|
+
cached for the suite and shared intentionally.
|
|
253
|
+
|
|
254
|
+
## Test model
|
|
255
|
+
|
|
256
|
+
A test is a named block.
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
test("name") do
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A test may request fixtures through required keyword arguments.
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
test("name") do |user:, article:|
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Smartest resolves these names and calls:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
context.instance_exec(**kwargs, &test_case.block)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The test body runs with `self` set to an `ExecutionContext`.
|
|
277
|
+
|
|
278
|
+
## Execution context
|
|
279
|
+
|
|
280
|
+
The execution context is the object used as `self` for test bodies.
|
|
281
|
+
|
|
282
|
+
Responsibilities:
|
|
283
|
+
|
|
284
|
+
- provide `expect`
|
|
285
|
+
- provide matchers such as `eq`
|
|
286
|
+
- provide test helper methods
|
|
287
|
+
- avoid polluting global objects
|
|
288
|
+
|
|
289
|
+
Tests are run as:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
context.instance_exec(**fixtures, &block)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
This keeps the top-level DSL small.
|
|
296
|
+
|
|
297
|
+
Only `test`, `fixture`, and `use_fixture` need to be globally available when using `smartest/autorun`.
|
|
298
|
+
|
|
299
|
+
## Core architecture
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
Smartest
|
|
303
|
+
└── Suite
|
|
304
|
+
├── TestRegistry
|
|
305
|
+
└── FixtureClassRegistry
|
|
306
|
+
|
|
307
|
+
Runner
|
|
308
|
+
├── loads TestCase objects
|
|
309
|
+
├── creates ExecutionContext
|
|
310
|
+
├── creates FixtureSet
|
|
311
|
+
├── resolves keyword fixtures
|
|
312
|
+
├── executes test body
|
|
313
|
+
├── runs cleanup
|
|
314
|
+
└── reports TestResult
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Runtime flow
|
|
318
|
+
|
|
319
|
+
Given:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
test("GET /me") do |logged_in_client:|
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
and fixtures:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
fixture :logged_in_client do |client:, user:|
|
|
330
|
+
client.login(user)
|
|
331
|
+
client
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
fixture :client do |server:|
|
|
335
|
+
Client.new(base_url: server.url)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
fixture :server do
|
|
339
|
+
server = TestServer.start
|
|
340
|
+
cleanup { server.stop }
|
|
341
|
+
server
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
fixture :user do
|
|
345
|
+
User.create!(email: "alice@example.com")
|
|
346
|
+
end
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Resolution:
|
|
350
|
+
|
|
351
|
+
```text
|
|
352
|
+
test requires logged_in_client
|
|
353
|
+
|
|
354
|
+
resolve logged_in_client
|
|
355
|
+
requires client
|
|
356
|
+
resolve client
|
|
357
|
+
requires server
|
|
358
|
+
resolve server
|
|
359
|
+
evaluate server block
|
|
360
|
+
register cleanup
|
|
361
|
+
cache server
|
|
362
|
+
evaluate client block with server:
|
|
363
|
+
cache client
|
|
364
|
+
requires user
|
|
365
|
+
resolve user
|
|
366
|
+
evaluate user block
|
|
367
|
+
cache user
|
|
368
|
+
evaluate logged_in_client block with client:, user:
|
|
369
|
+
cache logged_in_client
|
|
370
|
+
|
|
371
|
+
execute test body with logged_in_client:
|
|
372
|
+
|
|
373
|
+
run cleanup stack in reverse order
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Fixture caching
|
|
377
|
+
|
|
378
|
+
`FixtureSet` owns a cache for one fixture scope.
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
@cache = {
|
|
382
|
+
server: server_object,
|
|
383
|
+
client: client_object,
|
|
384
|
+
user: user_object
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
The test-scoped cache is created fresh for each test. The suite-scoped cache is
|
|
389
|
+
shared for the runner lifetime.
|
|
390
|
+
|
|
391
|
+
This keeps regular fixtures isolated while allowing explicit suite fixtures for
|
|
392
|
+
expensive shared resources.
|
|
393
|
+
|
|
394
|
+
## Cleanup stack
|
|
395
|
+
|
|
396
|
+
`FixtureSet` owns a cleanup stack for one fixture scope.
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
@cleanups = []
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Fixture blocks can call:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
cleanup { resource.close }
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
This delegates to:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
fixture_set.add_cleanup(&block)
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
For test-scoped fixtures, cleanup runs after the test in reverse order:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
@cleanups.reverse_each(&:call)
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
For suite-scoped fixtures, cleanup runs after all tests. Reverse order matters
|
|
421
|
+
because later resources may depend on earlier ones.
|
|
422
|
+
|
|
423
|
+
Example:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
fixture :server do
|
|
427
|
+
server = TestServer.start
|
|
428
|
+
cleanup { server.stop }
|
|
429
|
+
server
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
fixture :browser do |server:|
|
|
433
|
+
browser = Browser.launch(server.url)
|
|
434
|
+
cleanup { browser.close }
|
|
435
|
+
browser
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Cleanup should run:
|
|
440
|
+
|
|
441
|
+
```text
|
|
442
|
+
browser.close
|
|
443
|
+
server.stop
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## Dependency extraction
|
|
447
|
+
|
|
448
|
+
Smartest uses `Proc#parameters`.
|
|
449
|
+
|
|
450
|
+
Required keyword arguments:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
proc { |server:| }.parameters
|
|
454
|
+
# => [[:keyreq, :server]]
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Fixture dependencies are extracted from fixture blocks:
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
fixture :client do |server:|
|
|
461
|
+
end
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Test fixture usage is extracted from test blocks:
|
|
465
|
+
|
|
466
|
+
```ruby
|
|
467
|
+
test("name") do |client:|
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
MVP rule:
|
|
472
|
+
|
|
473
|
+
- `:keyreq` means fixture dependency or fixture usage
|
|
474
|
+
- positional parameters are invalid
|
|
475
|
+
- optional keyword parameters are not required for MVP
|
|
476
|
+
|
|
477
|
+
Future rule:
|
|
478
|
+
|
|
479
|
+
- `:key` may mean optional fixture injection
|
|
480
|
+
- if fixture exists, inject it
|
|
481
|
+
- otherwise let Ruby default value apply
|
|
482
|
+
|
|
483
|
+
## Invalid positional parameters
|
|
484
|
+
|
|
485
|
+
Smartest should reject this in tests:
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
test("bad") do |user|
|
|
489
|
+
end
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
and this in fixtures:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
fixture :client do |server|
|
|
496
|
+
end
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Reason:
|
|
500
|
+
|
|
501
|
+
- positional injection is ambiguous
|
|
502
|
+
- keyword injection is explicit
|
|
503
|
+
- the API should remain sharp
|
|
504
|
+
|
|
505
|
+
Suggested error for test:
|
|
506
|
+
|
|
507
|
+
```text
|
|
508
|
+
Positional fixture parameters are not supported.
|
|
509
|
+
|
|
510
|
+
Use keyword fixture injection:
|
|
511
|
+
|
|
512
|
+
test("bad") do |user:|
|
|
513
|
+
...
|
|
514
|
+
end
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Suggested error for fixture:
|
|
518
|
+
|
|
519
|
+
```text
|
|
520
|
+
Positional fixture dependencies are not supported.
|
|
521
|
+
|
|
522
|
+
Use keyword fixture dependencies:
|
|
523
|
+
|
|
524
|
+
fixture :client do |server:|
|
|
525
|
+
...
|
|
526
|
+
end
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
## Duplicate fixtures
|
|
530
|
+
|
|
531
|
+
If multiple registered fixture classes define the same fixture name, Smartest should fail.
|
|
532
|
+
|
|
533
|
+
Example:
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
class UserFixture < Smartest::Fixture
|
|
537
|
+
fixture :user do
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
class AdminFixture < Smartest::Fixture
|
|
542
|
+
fixture :user do
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
use_fixture UserFixture
|
|
547
|
+
use_fixture AdminFixture
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Error:
|
|
551
|
+
|
|
552
|
+
```text
|
|
553
|
+
duplicate fixture: user
|
|
554
|
+
defined in:
|
|
555
|
+
UserFixture
|
|
556
|
+
AdminFixture
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Detection should happen when a `FixtureSet` is created.
|
|
560
|
+
|
|
561
|
+
## Circular dependencies
|
|
562
|
+
|
|
563
|
+
This should fail:
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
fixture :a do |b:|
|
|
567
|
+
b
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
fixture :b do |a:|
|
|
571
|
+
a
|
|
572
|
+
end
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Error:
|
|
576
|
+
|
|
577
|
+
```text
|
|
578
|
+
circular fixture dependency: a -> b -> a
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Implementation uses a resolving stack:
|
|
582
|
+
|
|
583
|
+
```ruby
|
|
584
|
+
@resolving = []
|
|
585
|
+
|
|
586
|
+
def resolve(name)
|
|
587
|
+
return @cache[name] if @cache.key?(name)
|
|
588
|
+
|
|
589
|
+
if @resolving.include?(name)
|
|
590
|
+
raise CircularFixtureDependencyError
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
@resolving << name
|
|
594
|
+
# resolve
|
|
595
|
+
ensure
|
|
596
|
+
@resolving.pop if @resolving.last == name
|
|
597
|
+
end
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
## Fixture class inheritance
|
|
601
|
+
|
|
602
|
+
Fixture classes should support inheritance.
|
|
603
|
+
|
|
604
|
+
Example:
|
|
605
|
+
|
|
606
|
+
```ruby
|
|
607
|
+
class RailsFixture < Smartest::Fixture
|
|
608
|
+
fixture :app do
|
|
609
|
+
Rails.application
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
class UserFixture < RailsFixture
|
|
614
|
+
fixture :user do
|
|
615
|
+
User.create!(name: "Alice")
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
`UserFixture.fixture_definitions` should include both `:app` and `:user`.
|
|
621
|
+
|
|
622
|
+
Implementation approach:
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
def self.fixture_definitions
|
|
626
|
+
inherited =
|
|
627
|
+
if superclass.respond_to?(:fixture_definitions)
|
|
628
|
+
superclass.fixture_definitions
|
|
629
|
+
else
|
|
630
|
+
{}
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
inherited.merge(@fixture_definitions || {})
|
|
634
|
+
end
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Child definitions override parent definitions with the same name within the inheritance chain.
|
|
638
|
+
|
|
639
|
+
Duplicate detection applies across registered fixture classes, not parent-child internal merging.
|
|
640
|
+
|
|
641
|
+
## Fixture instances
|
|
642
|
+
|
|
643
|
+
Each test gets fresh fixture class instances for test-scoped fixtures.
|
|
644
|
+
|
|
645
|
+
```text
|
|
646
|
+
test A
|
|
647
|
+
WebFixture.new
|
|
648
|
+
cache: {}
|
|
649
|
+
|
|
650
|
+
test B
|
|
651
|
+
WebFixture.new
|
|
652
|
+
cache: {}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
This prevents instance variable leakage between tests.
|
|
656
|
+
|
|
657
|
+
Fixture block execution happens on the fixture instance:
|
|
658
|
+
|
|
659
|
+
```ruby
|
|
660
|
+
fixture_instance.instance_exec(**dependencies, &definition.block)
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
This allows fixture helper methods and `cleanup` to be private instance methods.
|
|
664
|
+
|
|
665
|
+
## Helper methods in fixtures
|
|
666
|
+
|
|
667
|
+
Fixture classes may have helper methods:
|
|
668
|
+
|
|
669
|
+
```ruby
|
|
670
|
+
class AppFixture < Smartest::Fixture
|
|
671
|
+
fixture :user do
|
|
672
|
+
create_user
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
private
|
|
676
|
+
|
|
677
|
+
def create_user
|
|
678
|
+
User.create!(name: "Alice")
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
Fixture blocks can call private methods because they execute with `instance_exec`.
|
|
684
|
+
|
|
685
|
+
Fixture classes may optionally delegate missing methods to the execution context.
|
|
686
|
+
|
|
687
|
+
This is useful for integration helpers.
|
|
688
|
+
|
|
689
|
+
Example:
|
|
690
|
+
|
|
691
|
+
```ruby
|
|
692
|
+
fixture :logged_in_user do |user:|
|
|
693
|
+
login_as(user)
|
|
694
|
+
user
|
|
695
|
+
end
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
If `login_as` is defined on `ExecutionContext`, `Fixture#method_missing` may delegate to it.
|
|
699
|
+
|
|
700
|
+
This should be used carefully. Fixture dependencies themselves should still be keyword arguments, not method-missing calls.
|
|
701
|
+
|
|
702
|
+
## Expectations
|
|
703
|
+
|
|
704
|
+
MVP expectation API:
|
|
705
|
+
|
|
706
|
+
```ruby
|
|
707
|
+
expect(actual).to eq(expected)
|
|
708
|
+
expect(actual).not_to eq(expected)
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
Internal model:
|
|
712
|
+
|
|
713
|
+
```text
|
|
714
|
+
expect(actual)
|
|
715
|
+
=> ExpectationTarget
|
|
716
|
+
|
|
717
|
+
eq(expected)
|
|
718
|
+
=> EqMatcher
|
|
719
|
+
|
|
720
|
+
ExpectationTarget#to(matcher)
|
|
721
|
+
=> matcher.match!(actual)
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Example:
|
|
725
|
+
|
|
726
|
+
```ruby
|
|
727
|
+
class Smartest::ExpectationTarget
|
|
728
|
+
def initialize(actual)
|
|
729
|
+
@actual = actual
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def to(matcher)
|
|
733
|
+
matcher.match!(@actual)
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def not_to(matcher)
|
|
737
|
+
matcher.not_match!(@actual)
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
Assertion failures should raise `Smartest::AssertionFailed`.
|
|
743
|
+
|
|
744
|
+
## Reporter
|
|
745
|
+
|
|
746
|
+
The initial reporter should be simple.
|
|
747
|
+
|
|
748
|
+
Example output:
|
|
749
|
+
|
|
750
|
+
```text
|
|
751
|
+
Running 3 tests
|
|
752
|
+
|
|
753
|
+
✓ factorial
|
|
754
|
+
✓ GET /health
|
|
755
|
+
✗ GET /me
|
|
756
|
+
|
|
757
|
+
Failures:
|
|
758
|
+
|
|
759
|
+
1) GET /me
|
|
760
|
+
expected 500 to eq 200
|
|
761
|
+
|
|
762
|
+
3 tests, 2 passed, 1 failed
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Future reporters may include:
|
|
766
|
+
|
|
767
|
+
- documentation reporter
|
|
768
|
+
- dot reporter
|
|
769
|
+
- JSON reporter
|
|
770
|
+
- GitHub Actions reporter
|
|
771
|
+
|
|
772
|
+
## CLI
|
|
773
|
+
|
|
774
|
+
The CLI should support:
|
|
775
|
+
|
|
776
|
+
```bash
|
|
777
|
+
bundle exec smartest
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
If no paths are given:
|
|
781
|
+
|
|
782
|
+
```bash
|
|
783
|
+
bundle exec smartest
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
should default to:
|
|
787
|
+
|
|
788
|
+
```text
|
|
789
|
+
smartest/**/*_test.rb
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
CLI flow:
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
require "smartest"
|
|
796
|
+
|
|
797
|
+
Kernel.include Smartest::DSL
|
|
798
|
+
$LOAD_PATH.unshift File.expand_path("smartest", Dir.pwd)
|
|
799
|
+
|
|
800
|
+
files = ARGV.empty? ? Dir["smartest/**/*_test.rb"] : ARGV
|
|
801
|
+
files.each { |file| require File.expand_path(file) }
|
|
802
|
+
|
|
803
|
+
exit Smartest::Runner.new.run
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
`smartest/autorun` should use `at_exit`.
|
|
807
|
+
|
|
808
|
+
```ruby
|
|
809
|
+
require "smartest"
|
|
810
|
+
|
|
811
|
+
Kernel.include Smartest::DSL
|
|
812
|
+
|
|
813
|
+
at_exit do
|
|
814
|
+
exit Smartest::Runner.new.run
|
|
815
|
+
end
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
Care must be taken not to run twice if both CLI and autorun are used.
|
|
819
|
+
|
|
820
|
+
## Exit status
|
|
821
|
+
|
|
822
|
+
- all tests passed: `0`
|
|
823
|
+
- any test failed: `1`
|
|
824
|
+
- configuration/load error: `1`
|
|
825
|
+
- interrupted: re-raise or exit non-zero
|
|
826
|
+
|
|
827
|
+
## Metadata
|
|
828
|
+
|
|
829
|
+
`test` should accept metadata:
|
|
830
|
+
|
|
831
|
+
```ruby
|
|
832
|
+
test("name", skip: true) do
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
test("name", tags: [:db]) do
|
|
836
|
+
end
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
MVP can store metadata without implementing all behavior.
|
|
840
|
+
|
|
841
|
+
Useful metadata later:
|
|
842
|
+
|
|
843
|
+
- `skip: true`
|
|
844
|
+
- `only: true`
|
|
845
|
+
- `tags: [:db]`
|
|
846
|
+
- `timeout: 5`
|
|
847
|
+
|
|
848
|
+
## Hooks
|
|
849
|
+
|
|
850
|
+
Hooks are separate from fixtures.
|
|
851
|
+
|
|
852
|
+
Potential API:
|
|
853
|
+
|
|
854
|
+
```ruby
|
|
855
|
+
before do
|
|
856
|
+
DatabaseCleaner.start
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
after do
|
|
860
|
+
DatabaseCleaner.clean
|
|
861
|
+
end
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
Hooks should run around each test.
|
|
865
|
+
|
|
866
|
+
Order:
|
|
867
|
+
|
|
868
|
+
```text
|
|
869
|
+
before hooks
|
|
870
|
+
fixture setup
|
|
871
|
+
test body
|
|
872
|
+
fixture cleanup
|
|
873
|
+
after hooks
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
Alternative order:
|
|
877
|
+
|
|
878
|
+
```text
|
|
879
|
+
fixture setup
|
|
880
|
+
before hooks
|
|
881
|
+
test body
|
|
882
|
+
after hooks
|
|
883
|
+
fixture cleanup
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
This needs a final decision later.
|
|
887
|
+
|
|
888
|
+
For MVP, hooks can be omitted.
|
|
889
|
+
|
|
890
|
+
Fixture cleanup already handles resource-specific teardown.
|
|
891
|
+
|
|
892
|
+
## Scoping
|
|
893
|
+
|
|
894
|
+
Fixtures are test-scoped by default.
|
|
895
|
+
|
|
896
|
+
Every test gets fresh test-scoped fixture instances and fixture values.
|
|
897
|
+
|
|
898
|
+
Expensive shared resources can use `suite_fixture`:
|
|
899
|
+
|
|
900
|
+
```ruby
|
|
901
|
+
suite_fixture :server do
|
|
902
|
+
server = TestServer.start
|
|
903
|
+
cleanup { server.stop }
|
|
904
|
+
server
|
|
905
|
+
end
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
Supported scopes:
|
|
909
|
+
|
|
910
|
+
- `:test`
|
|
911
|
+
- `:suite`
|
|
912
|
+
|
|
913
|
+
`fixture :name do ... end` creates a test-scoped fixture.
|
|
914
|
+
|
|
915
|
+
`suite_fixture :name do ... end` creates a suite-scoped fixture. It is lazy:
|
|
916
|
+
setup runs the first time a test requests it, and cleanup runs after all tests.
|
|
917
|
+
|
|
918
|
+
Test-scoped fixtures may depend on suite-scoped fixtures. Suite-scoped fixtures
|
|
919
|
+
may depend only on other suite-scoped fixtures.
|
|
920
|
+
|
|
921
|
+
File-scoped fixtures are not implemented.
|
|
922
|
+
|
|
923
|
+
## Parallel execution
|
|
924
|
+
|
|
925
|
+
MVP should not support parallel execution.
|
|
926
|
+
|
|
927
|
+
Current design can later support parallel execution if:
|
|
928
|
+
|
|
929
|
+
- each worker has an isolated suite or immutable suite definition
|
|
930
|
+
- each test has its own fixture set
|
|
931
|
+
- reporters are made thread/process safe
|
|
932
|
+
- global DSL registration is controlled
|
|
933
|
+
|
|
934
|
+
## Why class-based fixtures?
|
|
935
|
+
|
|
936
|
+
Top-level fixture definitions are simple:
|
|
937
|
+
|
|
938
|
+
```ruby
|
|
939
|
+
fixture(:user) do
|
|
940
|
+
end
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
But class-based fixtures are more Ruby-like for larger suites.
|
|
944
|
+
|
|
945
|
+
Benefits:
|
|
946
|
+
|
|
947
|
+
- grouping
|
|
948
|
+
- inheritance
|
|
949
|
+
- private helper methods
|
|
950
|
+
- reusable fixture modules
|
|
951
|
+
- clearer organization
|
|
952
|
+
- fewer global definitions
|
|
953
|
+
- natural place for cleanup helper
|
|
954
|
+
|
|
955
|
+
Example:
|
|
956
|
+
|
|
957
|
+
```ruby
|
|
958
|
+
class WebFixture < Smartest::Fixture
|
|
959
|
+
fixture :server do
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
private
|
|
963
|
+
|
|
964
|
+
def build_url(path)
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
## Fixture definition styles considered
|
|
970
|
+
|
|
971
|
+
### Plain public methods
|
|
972
|
+
|
|
973
|
+
```ruby
|
|
974
|
+
class AppFixture < Smartest::Fixture
|
|
975
|
+
def user
|
|
976
|
+
User.create!
|
|
977
|
+
end
|
|
978
|
+
end
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
Pros:
|
|
982
|
+
|
|
983
|
+
- very Ruby-like
|
|
984
|
+
- excellent editor support
|
|
985
|
+
- easy helper composition
|
|
986
|
+
|
|
987
|
+
Cons:
|
|
988
|
+
|
|
989
|
+
- unclear which public methods are fixtures
|
|
990
|
+
- harder to list fixtures
|
|
991
|
+
- harder to detect duplicates
|
|
992
|
+
- harder to attach metadata
|
|
993
|
+
- caching is less obvious
|
|
994
|
+
|
|
995
|
+
### `fixture :name do`
|
|
996
|
+
|
|
997
|
+
Chosen:
|
|
998
|
+
|
|
999
|
+
```ruby
|
|
1000
|
+
class AppFixture < Smartest::Fixture
|
|
1001
|
+
fixture :user do
|
|
1002
|
+
User.create!
|
|
1003
|
+
end
|
|
1004
|
+
end
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
Pros:
|
|
1008
|
+
|
|
1009
|
+
- explicit fixture declaration
|
|
1010
|
+
- easy metadata later
|
|
1011
|
+
- easy dependency extraction
|
|
1012
|
+
- easy duplicate detection
|
|
1013
|
+
- easy source locations
|
|
1014
|
+
- easy cleanup integration
|
|
1015
|
+
|
|
1016
|
+
### `fixture def user`
|
|
1017
|
+
|
|
1018
|
+
Considered:
|
|
1019
|
+
|
|
1020
|
+
```ruby
|
|
1021
|
+
fixture def user
|
|
1022
|
+
User.create!
|
|
1023
|
+
end
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
Pros:
|
|
1027
|
+
|
|
1028
|
+
- clever Ruby syntax
|
|
1029
|
+
- method-like
|
|
1030
|
+
|
|
1031
|
+
Cons:
|
|
1032
|
+
|
|
1033
|
+
- surprising
|
|
1034
|
+
- formatter/tooling concerns
|
|
1035
|
+
- less obvious for users
|
|
1036
|
+
|
|
1037
|
+
Not chosen for MVP.
|
|
1038
|
+
|
|
1039
|
+
## Resource fixtures considered
|
|
1040
|
+
|
|
1041
|
+
Considered:
|
|
1042
|
+
|
|
1043
|
+
```ruby
|
|
1044
|
+
resource :server do |use|
|
|
1045
|
+
server = TestServer.start
|
|
1046
|
+
use.call(server)
|
|
1047
|
+
ensure
|
|
1048
|
+
server&.stop
|
|
1049
|
+
end
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
Not chosen for MVP.
|
|
1053
|
+
|
|
1054
|
+
Reason:
|
|
1055
|
+
|
|
1056
|
+
- requires around-chain execution
|
|
1057
|
+
- complicates dependency handling
|
|
1058
|
+
- not needed if `cleanup` exists
|
|
1059
|
+
- makes fixture API more complex
|
|
1060
|
+
|
|
1061
|
+
Could be added later as advanced API.
|
|
1062
|
+
|
|
1063
|
+
## Final MVP API
|
|
1064
|
+
|
|
1065
|
+
```ruby
|
|
1066
|
+
# smartest/test_helper.rb
|
|
1067
|
+
require "smartest/autorun"
|
|
1068
|
+
|
|
1069
|
+
Dir[File.join(__dir__, "fixtures", "**", "*.rb")].sort.each do |fixture_file|
|
|
1070
|
+
require fixture_file
|
|
1071
|
+
end
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
```ruby
|
|
1075
|
+
# smartest/fixtures/app_fixture.rb
|
|
1076
|
+
class AppFixture < Smartest::Fixture
|
|
1077
|
+
fixture :user do
|
|
1078
|
+
User.create!(name: "Alice")
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
fixture :server do
|
|
1082
|
+
server = TestServer.start
|
|
1083
|
+
cleanup { server.stop }
|
|
1084
|
+
server
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
fixture :client do |server:|
|
|
1088
|
+
Client.new(base_url: server.url)
|
|
1089
|
+
end
|
|
1090
|
+
end
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
```ruby
|
|
1094
|
+
# smartest/example_test.rb
|
|
1095
|
+
require "test_helper"
|
|
1096
|
+
|
|
1097
|
+
use_fixture AppFixture
|
|
1098
|
+
|
|
1099
|
+
test("GET /health") do |client:|
|
|
1100
|
+
expect(client.get("/health").status).to eq(200)
|
|
1101
|
+
end
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
## Future ideas
|
|
1105
|
+
|
|
1106
|
+
Possible future features:
|
|
1107
|
+
|
|
1108
|
+
- `skip`
|
|
1109
|
+
- `only`
|
|
1110
|
+
- tags
|
|
1111
|
+
- custom reporters
|
|
1112
|
+
- JSON output
|
|
1113
|
+
- richer matchers
|
|
1114
|
+
- block expectations
|
|
1115
|
+
- `raise_error`
|
|
1116
|
+
- hooks
|
|
1117
|
+
- file-scoped fixtures
|
|
1118
|
+
- parallel execution
|
|
1119
|
+
- watch mode
|
|
1120
|
+
- Rails integration
|
|
1121
|
+
- Capybara integration
|
|
1122
|
+
- Playwright/Puppeteer integration
|
|
1123
|
+
- snapshot assertions
|
|
1124
|
+
- fixture graph visualization
|
|
1125
|
+
|
|
1126
|
+
## Design principles
|
|
1127
|
+
|
|
1128
|
+
1. Prefer explicit fixture names.
|
|
1129
|
+
2. Prefer Ruby keyword arguments over positional fixture injection.
|
|
1130
|
+
3. Keep fixture teardown optional.
|
|
1131
|
+
4. Keep fixture values test-scoped by default.
|
|
1132
|
+
5. Avoid global mutable state except the active suite used by the DSL.
|
|
1133
|
+
6. Keep MVP small.
|
|
1134
|
+
7. Make errors helpful.
|
|
1135
|
+
8. Do not copy RSpec's object model unless needed.
|
|
1136
|
+
9. Do not copy pytest syntax blindly; adapt it to Ruby.
|
|
1137
|
+
10. Make the common case beautiful.
|