@zerotal/arch 1.7.0
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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing
|
|
3
|
+
description: Boot your real app in tests and assert on HTTP, the database, console commands, browsers, and faked services.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
Zerotal ships a complete testing toolkit built on Bun's test runner. It covers HTTP
|
|
9
|
+
integration testing against a real running server, transactional database isolation,
|
|
10
|
+
console-command testing, end-to-end browser tests, in-memory service fakes, plus
|
|
11
|
+
[factories](/docs/orm/factories) and a data generator for arranging state.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# in your project root
|
|
15
|
+
bun test # run all test files
|
|
16
|
+
bun test --watch # re-run on change
|
|
17
|
+
bun test src/tests/PostTest.ts # a single file
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Everything is importable from `@zerotal/testing`, which is installed with the
|
|
21
|
+
default skeleton. To add it to an existing project:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# in your project root
|
|
25
|
+
bun add -d @zerotal/testing
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Setting up your app for testing
|
|
29
|
+
|
|
30
|
+
Do this once per project. Every test file — yours and the ones in the package
|
|
31
|
+
guides — assumes it exists.
|
|
32
|
+
|
|
33
|
+
A test needs an application that is configured for tests rather than for
|
|
34
|
+
development: an in-memory database, a synchronous queue, a log-driver mailer, a
|
|
35
|
+
known session secret. Building that inline in every file goes stale the first
|
|
36
|
+
time you add a provider, so build it once in `tests/helpers.ts` and import it
|
|
37
|
+
everywhere:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// tests/helpers.ts
|
|
41
|
+
import { Application } from "zerotal";
|
|
42
|
+
import { DatabaseProvider } from "@zerotal/orm";
|
|
43
|
+
import { SessionProvider } from "@zerotal/session";
|
|
44
|
+
import { AuthProvider } from "@zerotal/auth";
|
|
45
|
+
import { createTestApp, type TestApp } from "@zerotal/testing";
|
|
46
|
+
|
|
47
|
+
export function createApp(setup?: () => void): Promise<TestApp> {
|
|
48
|
+
return createTestApp(
|
|
49
|
+
() =>
|
|
50
|
+
Application.create({ env: "test" })
|
|
51
|
+
.register([DatabaseProvider, SessionProvider, AuthProvider])
|
|
52
|
+
.useConfig({
|
|
53
|
+
database: { url: ":memory:" },
|
|
54
|
+
session: { driver: "cookie", secret: "test-secret", cookie: "session", ttl: 7200 },
|
|
55
|
+
queue: { driver: "sync", connection: ":memory:" },
|
|
56
|
+
}),
|
|
57
|
+
setup,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The API template scaffolds this file for you. Add a provider to your app and you
|
|
63
|
+
add it here too — that one edit keeps every test in the suite honest.
|
|
64
|
+
|
|
65
|
+
From then on a test is two lines of setup:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// tests/http/posts.test.ts
|
|
69
|
+
import { test } from "bun:test";
|
|
70
|
+
import { createApp } from "../helpers.ts";
|
|
71
|
+
|
|
72
|
+
test("lists posts", async () => {
|
|
73
|
+
const app = await createApp();
|
|
74
|
+
|
|
75
|
+
const res = await app.get("/posts");
|
|
76
|
+
|
|
77
|
+
res.assertOk();
|
|
78
|
+
await app.close();
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
> **Note** — `createTestApp(bootstrap, setup?)` takes a **bootstrap callback**,
|
|
83
|
+
> not an application. It resets framework state, calls your callback, adopts the
|
|
84
|
+
> result as the current app, runs `setup`, and starts the server on a random
|
|
85
|
+
> port. Calling it without the callback will not compile.
|
|
86
|
+
|
|
87
|
+
Add a `test` script so the suite runs the same way everywhere:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
// package.json
|
|
91
|
+
{
|
|
92
|
+
"scripts": {
|
|
93
|
+
"test": "bun test"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`bun zt test` runs the same files with `APP_ENV=test` already set, which is
|
|
99
|
+
what you want when a test boots the app through your own `bootstrap/app.ts`
|
|
100
|
+
rather than through `createApp()`.
|
|
101
|
+
|
|
102
|
+
## The toolkit
|
|
103
|
+
|
|
104
|
+
| Area | What it covers |
|
|
105
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
106
|
+
| [HTTP Tests](/docs/testing/http) | Boot the app, send requests, assert on `TestResponse`; forms, uploads, auth, session. |
|
|
107
|
+
| [Console Tests](/docs/testing/console) | Run CLI commands in-process with `Artisan.call()` and assert output/exit code. |
|
|
108
|
+
| [Browser Tests](/docs/testing/browser) | End-to-end Playwright tests against a live server (Flow/Inertia UIs). |
|
|
109
|
+
| [Database](/docs/testing/database) | Migrations, transactional rollback per test, and `assertDatabase*`. |
|
|
110
|
+
| [Mocking](/docs/testing/mocking) | Event/queue/notification/broadcast/storage/HTTP fakes, the test clock, and `fake`. |
|
|
111
|
+
| [Flow Tests](/docs/flow/testing) | Drive a component's own lifecycle in-process — no server, no browser. |
|
|
112
|
+
| [Admin Tests](/docs/admin/testing) | Mount a panel resource's List / View / Form pages with resource-aware assertions. |
|
|
113
|
+
|
|
114
|
+
## Which test should I write?
|
|
115
|
+
|
|
116
|
+
- **Asserting on a route's status, body, or side effects?** Reach for an
|
|
117
|
+
[HTTP test](/docs/testing/http) — it exercises the full request lifecycle through
|
|
118
|
+
a real server.
|
|
119
|
+
- **Testing a form or a file upload?** Still an HTTP test, but send it the way a
|
|
120
|
+
browser does: [`postForm()` or `multipart()`](/docs/testing/http), not a JSON
|
|
121
|
+
`post()`. A JSON body does not travel the same path.
|
|
122
|
+
- **Asserting on rows after an action?** Pair the HTTP test with the
|
|
123
|
+
[`assertDatabase*` helpers](/docs/testing/database) and per-test rollback.
|
|
124
|
+
- **Testing a CLI command?** Use [Console tests](/docs/testing/console) to run it
|
|
125
|
+
in-process and assert on its output.
|
|
126
|
+
- **Testing what a Flow component does?** Use [Flow tests](/docs/flow/testing) —
|
|
127
|
+
they drive the component's real lifecycle without a server or a browser, which
|
|
128
|
+
is much faster than the HTTP or browser route.
|
|
129
|
+
- **Verifying a rendered UI end-to-end?** Use [Browser tests](/docs/testing/browser).
|
|
130
|
+
- **Need to confirm an email/job/notification/event happened without it happening?**
|
|
131
|
+
Install a [fake](/docs/testing/mocking) and assert on what it captured.
|
|
132
|
+
- **Behaviour that depends on time passing?** Freeze the clock with
|
|
133
|
+
[`Carbon.freeze()`](/docs/testing/mocking#time) instead of waiting.
|
|
134
|
+
|
|
135
|
+
## Generating a test
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# in your project root
|
|
139
|
+
bun zt make:test PostTest # tests/feature/PostTest.ts
|
|
140
|
+
bun zt make:test SlugTest --unit # tests/unit/SlugTest.ts
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The feature stub boots the app through `tests/helpers.ts`, so a generated test
|
|
144
|
+
runs against the same configured application as the rest of the suite rather than
|
|
145
|
+
building its own.
|
|
146
|
+
|
|
147
|
+
## A first test
|
|
148
|
+
|
|
149
|
+
`createTestApp()` boots your application, starts it on a random port, and returns a
|
|
150
|
+
`TestApp` client. Pair it with a [factory](/docs/orm/factories) to arrange data:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// tests/Feature/PostTest.ts
|
|
154
|
+
import { describe, it, beforeAll, afterAll } from "bun:test";
|
|
155
|
+
import { createTestApp, migrateDatabase, type TestApp, assertDatabaseHas } from "@zerotal/testing";
|
|
156
|
+
import { app } from "../bootstrap/app.ts";
|
|
157
|
+
import { UserFactory } from "../database/factories/UserFactory.ts";
|
|
158
|
+
|
|
159
|
+
let testApp: TestApp;
|
|
160
|
+
beforeAll(async () => {
|
|
161
|
+
testApp = await createTestApp(() => app);
|
|
162
|
+
await migrateDatabase(); // build the schema from database/migrations
|
|
163
|
+
});
|
|
164
|
+
afterAll(() => testApp.close());
|
|
165
|
+
|
|
166
|
+
describe("POST /posts", () => {
|
|
167
|
+
it("creates a post for an authenticated user", async () => {
|
|
168
|
+
const user = await UserFactory.create();
|
|
169
|
+
|
|
170
|
+
const res = await testApp.actingAs(user).post("/posts", { title: "Hello", slug: "hello" });
|
|
171
|
+
|
|
172
|
+
res.assertCreated();
|
|
173
|
+
await assertDatabaseHas("posts", { slug: "hello" });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rejects a post with no title", async () => {
|
|
177
|
+
const user = await UserFactory.create();
|
|
178
|
+
|
|
179
|
+
const res = await testApp.actingAs(user).asJson().post("/posts", { slug: "hello" });
|
|
180
|
+
|
|
181
|
+
res.assertUnprocessable().assertInvalid("title");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
See [HTTP Tests](/docs/testing/http) for the full `TestApp` and `TestResponse` API.
|
|
187
|
+
|
|
188
|
+
> **Tip** — `createTestApp(bootstrap, setup?)` takes an optional second callback
|
|
189
|
+
> that runs after the reset but before the server starts — register test-only
|
|
190
|
+
> routes there so they compile into the server.
|
|
191
|
+
|
|
192
|
+
## Arranging data
|
|
193
|
+
|
|
194
|
+
- **[Factories](/docs/orm/factories)** — generate model records (`Factory.define`, `create`, `for`, `state`, `count`).
|
|
195
|
+
- **[Seeding](/docs/seeding)** — seed reusable fixtures shared by dev and tests.
|
|
196
|
+
- **[Database](/docs/testing/database)** — keep tests isolated with per-test rollback.
|
|
197
|
+
|
|
198
|
+
## Resetting framework state
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// tests/Feature/SomeTest.ts
|
|
202
|
+
import { resetTestState } from "@zerotal/testing";
|
|
203
|
+
|
|
204
|
+
afterEach(() => resetTestState());
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`resetTestState()` disposes the current `Application` and clears the `Router`, ORM
|
|
208
|
+
observers, global scopes, and state-machine callbacks, plus framework event
|
|
209
|
+
subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
|
|
210
|
+
using those helpers don't need the explicit `afterEach`.
|
|
211
|
+
|
|
212
|
+
## References
|
|
213
|
+
|
|
214
|
+
The most-used members exported from `@zerotal/testing`. Each area's page documents
|
|
215
|
+
its full surface.
|
|
216
|
+
|
|
217
|
+
| Member | Signature | Description |
|
|
218
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
|
|
219
|
+
| `createTestApp` | `(bootstrap: () => Application \| Promise<Application>, setup?: () => void) => Promise<TestApp>` | Boot the app on a random port and return a client. |
|
|
220
|
+
| `TestApp#actingAs` | `(user: { id: number \| string }) => this` | Authenticate subsequent requests as `user`. |
|
|
221
|
+
| `TestApp#post` | `(url: string, body: unknown, headers?) => Promise<TestResponse>` | Send a JSON POST request. |
|
|
222
|
+
| `TestApp#close` | `() => Promise<void>` | Stop the server and reset state; call in `afterAll`. |
|
|
223
|
+
| `assertDatabaseHas` | `(table: string, where: Record<string, unknown>) => Promise<void>` | Assert a matching row exists. |
|
|
224
|
+
| `assertDatabaseMissing` | `(table: string, where: Record<string, unknown>) => Promise<void>` | Assert no matching row exists. |
|
|
225
|
+
| `assertDatabaseCount` | `(table: string, expected: number, where?) => Promise<void>` | Assert the row count for a table. |
|
|
226
|
+
| `migrateDatabase` | `(options?: MigrateDatabaseOptions) => Promise<string[]>` | Build the schema from the project's migrations. |
|
|
227
|
+
| `refreshDatabase` | `(options?: RefreshDatabaseOptions) => void` | Wrap each test in a transaction and roll back. |
|
|
228
|
+
| `resetTestState` | `() => void` | Dispose the app and clear framework/ORM state. |
|
|
229
|
+
| `Factory.define` | `(Model, (f) => FactoryPayload<T>) => Factory<T>` | Define a reusable model factory. |
|
|
230
|
+
| `fake` | `typeof fake` | South-African-flavoured random data generator. |
|
|
231
|
+
| `fakeFile` | `typeof fakeFile` | Real PNG/JPEG/GIF/PDF files for upload tests. |
|
|
232
|
+
|
|
233
|
+
## Next steps
|
|
234
|
+
|
|
235
|
+
- [HTTP Tests](/docs/testing/http) — the full `TestApp` and `TestResponse` API.
|
|
236
|
+
- [Database Tests](/docs/testing/database) — migrations and per-test rollback.
|
|
237
|
+
- [Console Tests](/docs/testing/console) — run CLI commands in-process.
|
|
238
|
+
- [Mocking](/docs/testing/mocking) — fakes for events, queue, notifications, broadcasts, storage, and outbound HTTP, plus the test clock.
|
|
239
|
+
- [Flow Tests](/docs/flow/testing) — drive a component in-process, with no server.
|
|
240
|
+
- [Admin Tests](/docs/admin/testing) — mount a panel resource's pages.
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Mocking
|
|
3
|
+
description: Swap real services for in-memory fakes so tests can assert side effects without doing real I/O.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Mocking
|
|
7
|
+
|
|
8
|
+
To test that code _triggers_ a side effect — queues a job, notifies a user,
|
|
9
|
+
broadcasts an event — swap the real service for an in-memory **fake**. The fake
|
|
10
|
+
records each call so you can assert on it, and performs no real I/O.
|
|
11
|
+
|
|
12
|
+
`EventFake`, `QueueFake`, and `NotificationFake` share an `install()` /
|
|
13
|
+
`restore()` lifecycle that swaps a container binding; broadcasting, storage, and
|
|
14
|
+
the HTTP client are faked through their own facades. All record-and-assert without
|
|
15
|
+
touching the network or the disk.
|
|
16
|
+
|
|
17
|
+
> **Danger** — A fake performs no real I/O. If a fake is still installed when a
|
|
18
|
+
> later test expects real delivery, jobs and notifications silently vanish —
|
|
19
|
+
> always restore it in `afterEach`.
|
|
20
|
+
|
|
21
|
+
## Which fake do I use?
|
|
22
|
+
|
|
23
|
+
- **Emitting a domain event?** Use `EventFake` — captures every `Events.emit()`
|
|
24
|
+
and stops its listeners from running.
|
|
25
|
+
- **Queueing a job?** Use `QueueFake` — captures every `Queue.dispatch()`.
|
|
26
|
+
- **Sending a notification** (including the `mail` channel)? Use
|
|
27
|
+
`NotificationFake` — captures every `Notify.send()` / `Notify.queue()`.
|
|
28
|
+
- **Broadcasting an event** over WebSockets? Use `Broadcast.fake()` — records
|
|
29
|
+
every `Broadcast.send()` / `Broadcast.to()`.
|
|
30
|
+
- **Writing a file to a disk?** Use `Storage.fake()` — an in-memory disk that
|
|
31
|
+
records what was stored.
|
|
32
|
+
- **Calling another service over HTTP?** Use `Http.fake()` — stubs outbound
|
|
33
|
+
requests so no real network call is made.
|
|
34
|
+
- **Signing in through an OAuth provider?** Use `Social.fake()` from
|
|
35
|
+
`@zerotal/auth` — returns a canned profile instead of exchanging a code.
|
|
36
|
+
- **Depending on the passage of time?** Freeze the clock with `Carbon.freeze()`
|
|
37
|
+
rather than installing a fake.
|
|
38
|
+
- **Need filler test data** (names, emails, dates)? Use [`fake`](#generating-fake-data),
|
|
39
|
+
the data generator — it has nothing to do with the service fakes above.
|
|
40
|
+
|
|
41
|
+
Everything except `Broadcast.fake()` and `Social.fake()` is re-exported from
|
|
42
|
+
`@zerotal/testing`, so a test rarely has to know which package a fake ships in.
|
|
43
|
+
|
|
44
|
+
## Events
|
|
45
|
+
|
|
46
|
+
`EventFake.install()` replaces the `events` binding, so emitted events are
|
|
47
|
+
recorded and their listeners never run.
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// src/tests/PostPublishTest.ts
|
|
51
|
+
import { EventFake } from "@zerotal/testing";
|
|
52
|
+
|
|
53
|
+
let events: EventFake;
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
events = EventFake.install();
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => events.restore());
|
|
58
|
+
|
|
59
|
+
it("announces that a post was published", async () => {
|
|
60
|
+
const post = await PostFactory.create({ status: "draft" });
|
|
61
|
+
|
|
62
|
+
await post.publish();
|
|
63
|
+
|
|
64
|
+
events.assertEmitted(PostPublished);
|
|
65
|
+
events.assertEmitted(PostPublished, (e) => e.postId === post.id);
|
|
66
|
+
events.assertNotEmitted(PostDeleted);
|
|
67
|
+
events.assertEmittedCount(PostPublished, 1);
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Faking the emitter is what lets this test assert that publishing _announces_
|
|
72
|
+
itself without also running everything that reacts to the announcement — mail,
|
|
73
|
+
search indexing, cache invalidation.
|
|
74
|
+
|
|
75
|
+
> **Warning** — To test a listener, do not fake. Construct the listener and hand
|
|
76
|
+
> it an event directly; the fake stops listeners from running at all.
|
|
77
|
+
|
|
78
|
+
## Queue
|
|
79
|
+
|
|
80
|
+
`QueueFake.install()` replaces the `queue` binding so dispatched jobs are
|
|
81
|
+
captured instead of persisted or executed.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// src/tests/PostPublishTest.ts
|
|
85
|
+
import { QueueFake } from "@zerotal/testing"; // re-exported from @zerotal/queue
|
|
86
|
+
|
|
87
|
+
let queue: QueueFake;
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
queue = QueueFake.install();
|
|
90
|
+
});
|
|
91
|
+
afterEach(() => queue.restore());
|
|
92
|
+
|
|
93
|
+
it("queues a job when a post is published", async () => {
|
|
94
|
+
const post = await PostFactory.create({ status: "draft" });
|
|
95
|
+
await post.transitionTo("published");
|
|
96
|
+
|
|
97
|
+
queue.assertDispatched(NotifySubscribersJob);
|
|
98
|
+
queue.assertDispatched(NotifySubscribersJob, (j) => j.postId === post.id);
|
|
99
|
+
queue.assertNotDispatched(ProcessPaymentJob);
|
|
100
|
+
queue.assertDispatchedCount(1);
|
|
101
|
+
queue.assertNothingDispatched(); // for isolation tests
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> **Warning** — The fake captures the job but never calls its `handle()`. To test
|
|
106
|
+
> the job's body, instantiate and `await` it directly instead of faking.
|
|
107
|
+
|
|
108
|
+
## Notifications
|
|
109
|
+
|
|
110
|
+
`NotificationFake.install()` replaces the `notifications` binding. Both
|
|
111
|
+
`Notify.send()` and `Notify.queue()` are captured as "sent". The `mail` channel
|
|
112
|
+
runs through `Notify`, so faking notifications also covers email side effects.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// src/tests/InvoiceTest.ts
|
|
116
|
+
import { NotificationFake } from "@zerotal/testing"; // re-exported from @zerotal/notifications
|
|
117
|
+
import { Notify } from "@zerotal/notifications";
|
|
118
|
+
|
|
119
|
+
let notify: NotificationFake;
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
notify = NotificationFake.install();
|
|
122
|
+
});
|
|
123
|
+
afterEach(() => notify.restore());
|
|
124
|
+
|
|
125
|
+
it("notifies the user when an invoice is ready", async () => {
|
|
126
|
+
const user = await UserFactory.create();
|
|
127
|
+
const invoice = await InvoiceFactory.create({ userId: user.id });
|
|
128
|
+
|
|
129
|
+
await Notify.send(user, new InvoiceReady(invoice));
|
|
130
|
+
|
|
131
|
+
notify.assertSentTo(user, InvoiceReady);
|
|
132
|
+
notify.assertSentTo(user, InvoiceReady, (n) => n.invoiceId === invoice.id);
|
|
133
|
+
notify.assertNotSentTo(adminUser, InvoiceReady);
|
|
134
|
+
notify.assertSentCount(1);
|
|
135
|
+
notify.assertNothingSent(); // for isolation tests
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Broadcasting
|
|
140
|
+
|
|
141
|
+
The `Broadcast` facade has its own recorder. Call `Broadcast.fake()` to install
|
|
142
|
+
it and `Broadcast.resetFake()` to restore — the returned fake exposes the
|
|
143
|
+
assertions:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// src/tests/PostBroadcastTest.ts
|
|
147
|
+
import { Broadcast } from "@zerotal/broadcasting";
|
|
148
|
+
|
|
149
|
+
const fake = Broadcast.fake();
|
|
150
|
+
|
|
151
|
+
await PostController.publish({ http: ctx });
|
|
152
|
+
|
|
153
|
+
fake.assertBroadcast("PostPublished", "posts");
|
|
154
|
+
fake.assertBroadcast("PostPublished", "posts", { id: post.id }); // partial payload match
|
|
155
|
+
fake.assertNothingBroadcast();
|
|
156
|
+
|
|
157
|
+
Broadcast.resetFake();
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`assertBroadcast(eventName, channel?, data?)` matches on the broadcast-as name
|
|
161
|
+
first, then the channel, then a partial payload. See
|
|
162
|
+
[Broadcasting › Testing](/docs/broadcasting/testing) for the full assertion set.
|
|
163
|
+
|
|
164
|
+
## Storage
|
|
165
|
+
|
|
166
|
+
`Storage.fake()` swaps a disk for an in-memory one and returns it. Nothing
|
|
167
|
+
touches the filesystem, so each test starts empty by construction rather than by
|
|
168
|
+
remembering to clean up — and a suite that forgets to clean up passes the second
|
|
169
|
+
time for the wrong reason.
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// src/tests/AvatarTest.ts
|
|
173
|
+
import { Storage } from "zerotal/storage";
|
|
174
|
+
import { fakeFile } from "@zerotal/testing";
|
|
175
|
+
|
|
176
|
+
let disk: ReturnType<typeof Storage.fake>;
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
disk = Storage.fake(); // or Storage.fake('s3') for a named disk
|
|
179
|
+
});
|
|
180
|
+
afterEach(() => Storage.restoreFakes());
|
|
181
|
+
|
|
182
|
+
it("stores an uploaded avatar", async () => {
|
|
183
|
+
const res = await app.actingAs(user).multipart("/avatar", {
|
|
184
|
+
avatar: fakeFile.image("me.png"),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
res.assertCreated();
|
|
188
|
+
disk.assertExistsMatching(/^avatars\/[0-9a-f-]+\.png$/);
|
|
189
|
+
disk.assertContentType(disk.paths()[0]!, "image/png");
|
|
190
|
+
disk.assertCount(1);
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Uploads are stored under a generated name, which is why `assertExistsMatching`
|
|
195
|
+
takes a pattern. When you control the path, `assertExists(path, contents?)` checks
|
|
196
|
+
it directly.
|
|
197
|
+
|
|
198
|
+
## Outbound HTTP
|
|
199
|
+
|
|
200
|
+
`Http.fake()` intercepts requests made through the [`Http`](/docs/helpers)
|
|
201
|
+
client, so a test that exercises a service integration makes no real network
|
|
202
|
+
call — and does not fail when that service is down or rate-limits you.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// src/tests/PaymentTest.ts
|
|
206
|
+
import { Http } from "@zerotal/testing";
|
|
207
|
+
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
Http.fake([
|
|
210
|
+
{ url: "https://api.payments.test/charges", body: { id: "ch_1", status: "paid" } },
|
|
211
|
+
{ url: "*", status: 404 }, // anything else is a mistake, and says so
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
afterEach(() => Http.resetFakes());
|
|
215
|
+
|
|
216
|
+
it("charges the card and records the reference", async () => {
|
|
217
|
+
await checkout.pay(order);
|
|
218
|
+
|
|
219
|
+
Http.assertSent((req) => req.url.includes("/charges") && req.method === "POST");
|
|
220
|
+
Http.assertSentCount(1);
|
|
221
|
+
await assertDatabaseHas("orders", { payment_reference: "ch_1" });
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
A catch-all stub at the end is worth adding: without it an unstubbed URL falls
|
|
226
|
+
through, and a test can quietly start depending on a service being reachable.
|
|
227
|
+
|
|
228
|
+
## Time
|
|
229
|
+
|
|
230
|
+
Behaviour that depends on the passage of time — a token that expires in seven
|
|
231
|
+
days, a reminder that only fires after 24 hours — has no way to be tested except
|
|
232
|
+
by waiting. Freeze the clock instead:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// src/tests/InvitationTest.ts
|
|
236
|
+
import { Carbon } from "zerotal";
|
|
237
|
+
|
|
238
|
+
afterEach(() => Carbon.release());
|
|
239
|
+
|
|
240
|
+
it("expires an invitation after seven days", async () => {
|
|
241
|
+
Carbon.freeze("2025-01-01T00:00:00Z");
|
|
242
|
+
const invitation = await InvitationFactory.create();
|
|
243
|
+
|
|
244
|
+
expect(invitation.isExpired()).toBe(false);
|
|
245
|
+
|
|
246
|
+
Carbon.travel({ days: 8 });
|
|
247
|
+
|
|
248
|
+
expect(invitation.isExpired()).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
| Member | Purpose |
|
|
253
|
+
| -------------------------- | ------------------------------------------------------------ |
|
|
254
|
+
| `Carbon.freeze(value?)` | Freeze at `value`, or at the current instant. |
|
|
255
|
+
| `Carbon.setTestNow(value)` | Freeze at an absolute point; `null` releases. |
|
|
256
|
+
| `Carbon.travelTo(value)` | Jump to an absolute point. |
|
|
257
|
+
| `Carbon.travel(amount)` | Move relative to now; negative values go back. |
|
|
258
|
+
| `Carbon.withTestNow(v, f)` | Freeze for the duration of `f`, releasing even if it throws. |
|
|
259
|
+
| `Carbon.release()` | Let the clock run normally again. |
|
|
260
|
+
| `Carbon.isFrozen()` | Whether the clock is currently frozen. |
|
|
261
|
+
|
|
262
|
+
> **Danger** — Always release the clock in an `afterEach`. A frozen clock that
|
|
263
|
+
> outlives its test makes the next one fail somewhere unrelated.
|
|
264
|
+
|
|
265
|
+
This moves Carbon's clock, which is what `Carbon.now()` and everything built on
|
|
266
|
+
it read — `isPast`, `isToday`, `diffForHumans`, a model's timestamps. A raw
|
|
267
|
+
`Date.now()` is unaffected, so code that must be testable against time should go
|
|
268
|
+
through [Carbon](/docs/carbon).
|
|
269
|
+
|
|
270
|
+
## Generating fake data
|
|
271
|
+
|
|
272
|
+
`fake` is a zero-dependency, South-African-flavoured data generator for filling
|
|
273
|
+
in test values — used inside [factories](/docs/orm/factories) or directly in a
|
|
274
|
+
test. It is unrelated to the service fakes above.
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
// src/tests/example.ts
|
|
278
|
+
import { fake } from "@zerotal/testing";
|
|
279
|
+
|
|
280
|
+
// Identity
|
|
281
|
+
fake.name(); // "Sipho Dlamini"
|
|
282
|
+
fake.email(); // "sipho.dlamini73@gmail.com"
|
|
283
|
+
fake.email({ corporate: true }); // "sipho.dlamini@shoprite.co.za"
|
|
284
|
+
fake.phone(); // "071 234 5678"
|
|
285
|
+
|
|
286
|
+
// Location & business
|
|
287
|
+
fake.city();
|
|
288
|
+
fake.province();
|
|
289
|
+
fake.suburb();
|
|
290
|
+
fake.streetAddress();
|
|
291
|
+
fake.postalCode();
|
|
292
|
+
fake.company();
|
|
293
|
+
fake.jobTitle();
|
|
294
|
+
fake.department();
|
|
295
|
+
|
|
296
|
+
// Primitives
|
|
297
|
+
fake.string(10); // random alphanumeric
|
|
298
|
+
fake.number(1, 1000); // integer in [1, 1000]
|
|
299
|
+
fake.float(0, 1, 2); // 2-decimal float
|
|
300
|
+
fake.boolean(0.8); // true 80% of the time
|
|
301
|
+
fake.uuid();
|
|
302
|
+
fake.maybe("val", 0.5); // value or null
|
|
303
|
+
|
|
304
|
+
// Dates
|
|
305
|
+
fake.date();
|
|
306
|
+
fake.pastDate(3);
|
|
307
|
+
fake.futureDate(2);
|
|
308
|
+
fake.isoDate();
|
|
309
|
+
fake.timestamp();
|
|
310
|
+
|
|
311
|
+
// Text
|
|
312
|
+
fake.word();
|
|
313
|
+
fake.words(5);
|
|
314
|
+
fake.sentence();
|
|
315
|
+
fake.paragraph();
|
|
316
|
+
fake.title();
|
|
317
|
+
fake.slug("My Title");
|
|
318
|
+
|
|
319
|
+
// Arrays
|
|
320
|
+
fake.pick([1, 2, 3]); // random element
|
|
321
|
+
fake.shuffle([1, 2, 3]); // shuffled copy
|
|
322
|
+
fake.sample([1, 2, 3, 4], 2); // 2 unique random picks
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## References
|
|
326
|
+
|
|
327
|
+
`EventFake` — captures emitted events:
|
|
328
|
+
|
|
329
|
+
| Method | Signature | Description |
|
|
330
|
+
| ---------------------- | --------------------------------------------- | --------------------------------------------------------- |
|
|
331
|
+
| `install` | `static install(): EventFake` | Swap the `events` binding for the fake. |
|
|
332
|
+
| `restore` | `restore(): void` | Restore the original `events` binding. |
|
|
333
|
+
| `emitted` | `emitted(): object[]` | All captured events, in emit order. |
|
|
334
|
+
| `emittedOf` | `emittedOf(EventClass): T[]` | The captured events of one class, typed. |
|
|
335
|
+
| `assertEmitted` | `assertEmitted(EventClass, filter?): void` | An event of that class was emitted (optionally matching). |
|
|
336
|
+
| `assertNotEmitted` | `assertNotEmitted(EventClass, filter?): void` | No such event was emitted. |
|
|
337
|
+
| `assertEmittedCount` | `assertEmittedCount(EventClass, count): void` | Exactly `count` events of that class were emitted. |
|
|
338
|
+
| `assertNothingEmitted` | `assertNothingEmitted(): void` | No events were emitted at all. |
|
|
339
|
+
| `clear` | `clear(): void` | Discard what has been captured so far. |
|
|
340
|
+
|
|
341
|
+
`FakeDisk` — returned by `Storage.fake()`:
|
|
342
|
+
|
|
343
|
+
| Method | Signature | Description |
|
|
344
|
+
| ---------------------- | --------------------------------------------- | ------------------------------------------------------ |
|
|
345
|
+
| `assertExists` | `assertExists(path, contents?): this` | A file is stored at `path` (optionally with contents). |
|
|
346
|
+
| `assertMissing` | `assertMissing(path): this` | Nothing is stored at `path`. |
|
|
347
|
+
| `assertExistsMatching` | `assertExistsMatching(pattern: RegExp): this` | Some stored path matches `pattern`. |
|
|
348
|
+
| `assertContentType` | `assertContentType(path, type): this` | The file was stored with that content type. |
|
|
349
|
+
| `assertCount` | `assertCount(expected): this` | Exactly `expected` files are stored. |
|
|
350
|
+
| `assertNothingStored` | `assertNothingStored(): this` | Nothing was stored. |
|
|
351
|
+
| `paths` / `file` | `paths(): string[]` / `file(path)` | Inspect what is stored. |
|
|
352
|
+
| `clear` | `clear(): void` | Empty the disk. |
|
|
353
|
+
|
|
354
|
+
`Http` — the outbound HTTP client's own harness:
|
|
355
|
+
|
|
356
|
+
| Method | Signature | Description |
|
|
357
|
+
| ------------------- | ------------------------------------ | --------------------------------------------------- |
|
|
358
|
+
| `fake` | `static fake(stubs?): void` | Intercept outbound requests with stubbed responses. |
|
|
359
|
+
| `resetFakes` | `static resetFakes(): void` | Restore real HTTP behaviour. |
|
|
360
|
+
| `recorded` | `static recorded(): Array<…>` | Every request recorded since `fake()`. |
|
|
361
|
+
| `assertSent` | `static assertSent(predicate): void` | A matching request was sent. |
|
|
362
|
+
| `assertNotSent` | `static assertNotSent(pred): void` | No matching request was sent. |
|
|
363
|
+
| `assertSentCount` | `static assertSentCount(n): void` | Exactly `n` requests were sent. |
|
|
364
|
+
| `assertNothingSent` | `static assertNothingSent(): void` | No requests were sent at all. |
|
|
365
|
+
|
|
366
|
+
`QueueFake` — captures dispatched jobs:
|
|
367
|
+
|
|
368
|
+
| Method | Signature | Description |
|
|
369
|
+
| ------------------------- | ------------------------------------------- | ------------------------------------------------------------------ |
|
|
370
|
+
| `install` | `static install(): QueueFake` | Swap the `queue` binding for the fake. |
|
|
371
|
+
| `restore` | `restore(): void` | Restore the original `queue` binding. |
|
|
372
|
+
| `dispatched` | `dispatched(): Job[]` | All captured jobs. |
|
|
373
|
+
| `assertDispatched` | `assertDispatched(JobClass, filter?): void` | A job of that class was dispatched (optionally matching `filter`). |
|
|
374
|
+
| `assertNotDispatched` | `assertNotDispatched(JobClass): void` | No job of that class was dispatched. |
|
|
375
|
+
| `assertNothingDispatched` | `assertNothingDispatched(): void` | No jobs were dispatched at all. |
|
|
376
|
+
| `assertDispatchedCount` | `assertDispatchedCount(count): void` | Exactly `count` jobs were dispatched. |
|
|
377
|
+
|
|
378
|
+
`NotificationFake` — captures sent notifications:
|
|
379
|
+
|
|
380
|
+
| Method | Signature | Description |
|
|
381
|
+
| ------------------- | ------------------------------------------------------------ | -------------------------------------------------------- |
|
|
382
|
+
| `install` | `static install(): NotificationFake` | Swap the `notifications` binding for the fake. |
|
|
383
|
+
| `restore` | `restore(): void` | Restore the original `notifications` binding. |
|
|
384
|
+
| `sent` | `sent(): CapturedNotification[]` | All captured `{ notifiable, notification }` pairs. |
|
|
385
|
+
| `assertSentTo` | `assertSentTo(notifiable, NotificationClass, filter?): void` | A notification of that class was sent to the notifiable. |
|
|
386
|
+
| `assertNotSentTo` | `assertNotSentTo(notifiable, NotificationClass): void` | That class was not sent to the notifiable. |
|
|
387
|
+
| `assertNothingSent` | `assertNothingSent(): void` | No notifications were sent at all. |
|
|
388
|
+
| `assertSentCount` | `assertSentCount(count): void` | Exactly `count` notifications were sent. |
|
|
389
|
+
|
|
390
|
+
`BroadcastFake` — returned by `Broadcast.fake()`:
|
|
391
|
+
|
|
392
|
+
| Method | Signature | Description |
|
|
393
|
+
| ------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
394
|
+
| `recorded` | `recorded(): RecordedBroadcast[]` | All recorded `{ channel, event, data }` broadcasts. |
|
|
395
|
+
| `reset` | `reset(): void` | Clear recorded broadcasts. |
|
|
396
|
+
| `assertBroadcast` | `assertBroadcast(eventName, channel?, data?): void` | An event was broadcast (optionally on `channel`, with a partial `data` match). |
|
|
397
|
+
| `assertNotBroadcast` | `assertNotBroadcast(eventName, channel?): void` | That event was not broadcast. |
|
|
398
|
+
| `assertNothingBroadcast` | `assertNothingBroadcast(): void` | No broadcasts were recorded. |
|
|
399
|
+
| `assertBroadcastCount` | `assertBroadcastCount(count): void` | Exactly `count` broadcasts were recorded. |
|
|
400
|
+
|
|
401
|
+
## Next steps
|
|
402
|
+
|
|
403
|
+
- [Factories](/docs/orm/factories) — where `fake` is most often used.
|
|
404
|
+
- [Events](/docs/events) — the real emitter `EventFake` stands in for.
|
|
405
|
+
- [Queue](/docs/queue) — the real queue `QueueFake` stands in for.
|
|
406
|
+
- [Notifications](/docs/notifications) — the real notifier `NotificationFake` stands in for, including the `mail` channel.
|
|
407
|
+
- [Storage](/docs/storage) — the disks `Storage.fake()` stands in for.
|
|
408
|
+
- [Carbon](/docs/carbon) — the date-time API whose clock the time helpers move.
|
|
409
|
+
- [Console Tests](/docs/testing/console) — assert side effects of CLI commands.
|
|
410
|
+
- [Database](/docs/testing/database) — assert rows alongside faked side effects.
|