Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions docs/testing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ end)
Spec files should be comment-free apart from lint directives (`--!strict`, `-- selene:`) and the
`@class` docstring — the code and test names carry the intent. Do **not** write prose headers above
`setup()` or other helpers describing what they build, comments narrating test flow, or comments
restating what an assertion checks — `setup()`/`destroy()` is an established pattern and needs no
restating what an assertion checks — `setup()`/`Destroy()` is an established pattern and needs no
explanation. The only comment worth keeping is one documenting something impossible to infer from
the code (e.g. an engine-bug workaround, with a link). When in doubt, omit it.

Expand All @@ -65,12 +65,12 @@ leaves running keeps executing after the test ends and can throw during a *later
which the runner reports as that innocent later package failing. A leaked `DataStore` auto-save loop
throwing during the `secrets` suite is a real example we hit.

So every object a test constructs must be torn down. Use the **`setup()` / `destroy()` controller
So every object a test constructs must be torn down. Use the **`setup()` / `Destroy()` controller
pattern** — the standard across the codebase (see the `rogue-properties` specs and
`saveslot/.../HasSaveSlots.spec.lua`). A local `setup()` creates a `Maid`, `maid:Add`s the
`ServiceBag` and every object it builds, and returns a controller: named fields plus factory
functions, and a `destroy` that cleans the maid. Each test calls `setup()`, does its work, and calls
`controller:destroy()` at the end. This is the same object-ownership idiom the Hoarcekat stories use.
functions, and a `Destroy` that cleans the maid. Each test calls `setup()`, does its work, and calls
`controller:Destroy()` at the end. This is the same object-ownership idiom the Hoarcekat stories use.

```luau
local function setup()
Expand All @@ -85,7 +85,7 @@ local function setup()

return {
thing = thing,
destroy = function()
Destroy = function(_self)
maid:DoCleaning()
end,
}
Expand All @@ -94,7 +94,7 @@ end
it("does a thing", function()
local controller = setup()
expect(controller.thing:DoSomething()).toEqual(true)
controller:destroy()
controller:Destroy()
end)
```

Expand All @@ -105,7 +105,7 @@ Guidelines:
loop, a subscription) running otherwise. This is the exact bug that leaked into the `secrets` suite.
- For objects a test builds on demand (multiple stores, per-test config), expose a factory that
returns `maid:Add(X.new(...))` — e.g. a `newDataStore()` on the controller.
- In a hung-promise guard that returns early, call `controller:destroy()` before the `return` so the
- In a hung-promise guard that returns early, call `controller:Destroy()` before the `return` so the
early exit still cleans up.
- A test may still `:Destroy()` an object mid-test when that teardown *is* the behavior under test —
the maid safely skips an already-destroyed object at `DoCleaning` (a destroyed `BaseObject` has its
Expand All @@ -114,14 +114,45 @@ Guidelines:
`object:Destroy()` at the end (and in any early-return guard).

**Read a failure list from the top.** A failing `expect` throws, so the trailing
`controller:destroy()` never runs and that test leaks everything it built into the shared place.
`controller:Destroy()` never runs and that test leaks everything it built into the shared place.
Later tests then fail for reasons of their own — timing out on an observable, seeing a slot that
should have been filtered — and those look like independent bugs. They are usually one bug plus its
wake. Fix the earliest failure and re-run before investigating any of the others; the count often
drops by more than one. (This is why the guideline above matters even though it reads as
belt-and-braces: teardown you only reach on the happy path is teardown you lose exactly when a test
is failing.)

**`JestUtils.afterThis` queues cleanup that survives a failed assertion.** `@quenty/jestutils` takes
any maid task — a function, an `Instance`, a connection, a thread, anything with `Destroy` — next to
the code that creates it, and unwinds the queue in reverse once the test finishes, pass or fail:

```luau
local JestUtils = require("JestUtils")

it("does a thing", function()
local controller = setup()
JestUtils.afterThis(controller)

local thing = SomeClass.new()
JestUtils.afterThis(thing)

expect(thing:DoSomething()).toEqual(true)
end)
```

The queue unwinds from a Jest `afterEach`, so a throwing `expect` no longer strands what the test
built. Every queued task runs even when an earlier one throws, and the failures are reported together
against the test that queued them.

`afterThis` returns a function that unqueues the task again, and that function is itself a maid task.
An object that already cleans up on its own can hand it back to its own maid, so whichever happens
first wins and nothing is destroyed twice:

```luau
local maid = Maid.new()
maid:GiveTask(JestUtils.afterThis(maid))
```

### Consume every rejection (or Jest passes but the run still fails)

Jest only tracks assertions that run inside an `it`. Any **uncaught Luau error** raised outside that —
Expand Down
Loading
Loading