Testing
Maat ships an in-process HTTP test client. It drives your real application — the same kernel, middleware stack, routes and exception handler that serve production — without opening a socket, so a full request/response test runs in microseconds.
import 'package:maat/maat.dart';import 'package:maat/testing.dart';import 'package:test/test.dart';
void main() { late Application app;
setUp(() async { app = await createApp(); });
tearDown(() => Application.reset());
test('creates a post through the HTTP boundary', () async { final response = await TestClient(app).postJson('/api/posts', { 'title': 'Hello Maat', 'body': 'A first post.', });
response .assertCreated() .assertJson({'title': 'Hello Maat'}) .assertHeaderContains('content-type', 'application/json'); });}The testing helpers live in a separate import — package:maat/testing.dart — so they never reach your production code.
Making Requests
final client = TestClient(app);
await client.get('/posts');await client.get('/posts?page=2');await client.post('/posts', form: {'title': 'Hello'});await client.postJson('/posts', {'title': 'Hello'});await client.putJson('/posts/1', {'title': 'Edited'});await client.patchJson('/posts/1', {'title': 'Edited'});await client.delete('/posts/1');await client.deleteJson('/posts/1', {'reason': 'spam'});await client.json('REPORT', '/posts/1', {'reason': 'spam'});Every method accepts per-request headers as its last argument.
Headers and Authentication
final client = TestClient(app) .withHeader('x-tenant', 'acme') .withHeaders({'accept-language': 'ar'}) .withToken('a-token'); // sets Authorization: Bearer a-tokenEach of these returns a new client rather than mutating the original, so a configured client can be shared across tests without leaking state. Per-request headers win over the client’s own.
Assertions
Assertions are chainable and each returns the response:
response .assertCreated() .assertJson({'title': 'Hello'}) .assertHeader('content-type', 'application/json; charset=utf-8');Status
| Assertion | Status |
|---|---|
assertStatus(code) |
any |
assertOk() |
200 |
assertCreated() |
201 |
assertNoContent() |
204 |
assertUnauthorized() |
401 |
assertForbidden() |
403 |
assertNotFound() |
404 |
assertUnprocessable() |
422 |
JSON
response.assertJson({'title': 'Hello'}); // a subset — other keys may existresponse.assertJsonPath('author.name', 'Ada'); // dot notation, through lists tooresponse.assertJsonPath('tags.0', 'dart');assertJson checks a subset, so you assert what you care about and ignore the rest. A key you name that is absent from the body fails — absent is not the same as null.
Validation
response.assertUnprocessable().assertJsonValidationErrors(['title', 'body']);response.assertJsonMissingValidationErrors(); // no errors at allresponse.assertJsonMissingValidationErrors(['title']); // not these onesHeaders and Content
response.assertHeader('x-request-id', 'abc');response.assertHeaderContains('content-type', 'json');response.assertSee('<h1>Welcome</h1>');response.assertRedirect();response.assertRedirect('/login');Reading the Response Directly
response.statusCode;response.body; // Stringresponse.headers;response.json; // decodedA failed assertion throws TestClientAssertionError naming what was expected and what actually arrived, so a red test tells you the difference without a debugger. Calling a JSON assertion on a body that is not JSON fails the same way, rather than throwing a decode error.
Faking Events
Event.fake() records events without running their listeners:
test('publishing dispatches PostPublished', () async { final events = Event.fake();
(await client.post('/api/posts/1/publish')).assertOk();
events.assertDispatched<PostPublished>(); events.assertDispatched<PostPublished>((event) => event.postId == 1); events.assertDispatchedTimes<PostPublished>(1); events.assertNotDispatched<PostArchived>();});| Assertion | Passes when |
|---|---|
assertDispatched<E>([where]) |
At least one E was recorded, optionally matching where. |
assertDispatchedTimes<E>(count) |
Exactly count events of type E were recorded. |
assertNotDispatched<E>([where]) |
No matching E was recorded. |
assertNothingDispatched() |
Nothing was recorded. |
dispatched<E>([where]) |
Returns matching events for your own assertions. |
Fake selected event types while letting other events reach their real listeners:
final events = Event.fake(only: [PostPublished]);Listeners registered after faking still reach the real dispatcher, so
Event.hasListeners<E>() remains accurate.
Faking Mail
Mail.fake() swaps the application’s MailManager binding and records mail
without rendering a view or calling a transport:
test('publishing sends its message', () async { final mail = Mail.fake();
(await client.post('/api/posts/1/publish')).assertOk();
mail.assertSent<PostPublishedMail>(); mail.assertSent<PostPublishedMail>((message) => message.post.id == 1); mail.assertNotSent<PostArchivedMail>(); mail.assertSentCount(1);});Use mail.sent<T>() for custom assertions, mail.emails for raw or directly
assembled emails, and mail.assertNothingSent() when no mail should leave the
request. Build a fresh application in setUp so each test gets a fresh fake.
Faking Notifications
Notification.fake() records the recipient, notification, and selected
channels without running a channel:
final notifications = Notification.fake();
await author.notify(PostPublishedNotification(post));
notifications.assertSentTo<PostPublishedNotification>(author);notifications.assertSentTo<PostPublishedNotification>( author, (message, channels) => message.post.id == post.id,);notifications.assertNotSentTo<PasswordResetNotification>(author);notifications.assertSentToTimes<PostPublishedNotification>(author, 1);notifications.assertCount(1);Use assertSentOnDemand<T>() for Notification.route(...) deliveries,
assertNothingSentTo(user) for one recipient, and assertNothingSent() for
the whole test. Persisted models match by runtime type and key, so a freshly
loaded instance can assert notifications sent to the same row.
Resetting Between Tests
The framework keeps global state — the current application, config and environment. Reset it between tests:
tearDown(() => Application.reset());Build one application per test and do not interleave requests between two applications in the same process. The global helpers (app(), config(), route()) always point at the most recently created application.
A Fresh Database Between Tests
RefreshDatabase is Laravel’s trait of the same name, for package:test: a fresh in-memory SQLite database, migrated once, emptied after every test so no row leaks into the next one.
import 'package:seshat_maat/seshat_maat.dart';import 'package:seshat_maat/testing.dart';import 'package:test/test.dart';
void main() { final db = RefreshDatabase(migrations: [CreatePostsTable()]);
setUpAll(db.migrate); tearDown(db.truncate); tearDownAll(db.close);
test('a post can be created', () async { final post = await PostFactory().createOne();
expect(await Post.def.query().count(), 1); expect(post.key, isNotNull); });}It hands the hooks back rather than registering them, so seshat_maat needs no dependency on a test runner. Wire them yourself:
| Hook | Wire into | What it does |
|---|---|---|
migrate |
setUpAll |
Opens the database, makes it the default connection, runs the migrations. |
truncate |
tearDown |
Deletes every row and resets autoincrement counters. The schema stays. |
close |
tearDownAll |
Closes the connection and forgets it. Optional, but do it when the file shares its isolate with other database tests. |
migratebelongs insetUpAll, notsetUp. The connection islate final, so migrating twice throws rather than quietly replacing the database mid-suite.
Truncation runs inside a transaction with defer_foreign_keys on, so tables are emptied in any order and constraints are checked at commit — by which point every table is empty. Foreign keys are still enforced afterwards; the pragma is transaction-scoped and SQLite clears it on commit.
Laravel wraps each test in a transaction and rolls it back.
Connectionexposes only the callback formtransaction(body), and a callback scope cannot spanpackage:test’s separatesetUp/ body /tearDowncalls, sotruncatedeletes rows instead. The observable behaviour is the same, including ids restarting at 1.
dart testisolates per file, not per test. Anything static — aModelBindingregistration, a custom validation rule, a global scope — survives from one test to the next inside the same file, and the order they run in is not fixed. Reset it insetUportearDown, or a suite that passes today fails under--test-randomize-ordering-seed=randomtomorrow.
Model Factories
A factory describes how to build one model. Laravel finds it by naming convention; Dart has no reflection, so a factory names its model’s ModelDefinition explicitly and you construct the class yourself.
class PostFactory extends Factory<Post> { PostFactory({super.faker, super.count, super.states}) : super(Post.def);
@override Map<String, Object?> definition(Faker faker) => { 'title': faker.sentence(), 'published': true, };
@override PostFactory state(Map<String, Object?> attributes) => PostFactory( faker: faker, count: countOf, states: [...states, attributes], );
@override PostFactory count(int n) => PostFactory(faker: faker, count: n, states: states);
PostFactory draft() => state({'published': false});}Generate the skeleton with:
maat make:factory PostFactorymaat make:factory AuthorFactory --model=Usermake:model Post -f --fields "title:string published:bool" writes the model and a factory with a faker value per field.
stateandcountare abstract, and yours must return a NEW factory. Laravel clones$thisreflectively; Dart cannot, so only your subclass can build another of itself. An implementation that mutates and returnsthisleaks the first test’s states into every later test that shares the factory — and the leak is silent, because the wrong data still saves. The generated stub is the correct pattern; copy it.
Building and Creating
final post = PostFactory().makeOne(); // in memory, nothing writtenfinal posts = PostFactory().count(3).make(); // three, in memoryfinal saved = await PostFactory().createOne(); // inserted, with its keyfinal many = await PostFactory().count(3).create(); // three rowscreateOne and create return the persisted instances, which is where the generated key lives — models are immutable, so save() returns a new instance rather than filling in this.
States apply over the definition in order, and a later state wins:
await PostFactory().draft().state({'title': 'Fixed'}).count(2).create();Relationships
has creates children for every model the factory persists, with the foreign key pointing at the parent:
await UserFactory().has(PostFactory().count(3), 'posts').createOne();belongsTo is the other direction — what this factory creates belongs to a parent that already exists:
final user = await UserFactory().createOne();await PostFactory().belongsTo(user, 'user').count(5).create();Both name the relation explicitly. Laravel infers it from the child’s class, and Maat could infer it too — the model definition names every relation and each one carries the model it points at. It does not, because two relations can target the same model (author and editor, both to User), so inference is ambiguous exactly where it matters. The name is passed and checked against the definition instead — a typo throws immediately, naming the relations that do exist, rather than writing parentless rows. belongsTo also refuses a parent that has not been saved, because its key would be written as null.
This is
->for($user)in Laravel.foris a Dart keyword, so it cannot be a method name —belongsTois not just the rename, it is also where the relation gets a name. See Coming From Laravel for the rest of the spellings a Laravel habit will reach for and get wrong.
hasreturns aFactory<T>, not your subclass, so apply your named states before it:UserFactory().inactive().has(...), neverUserFactory().has(...).inactive().
Faker
Every factory carries a Faker. Pass a seed to replay a sequence byte for byte, which is how you re-run a failing test:
PostFactory(faker: Faker(1234));| Method | Returns |
|---|---|
name() |
'Linus Dijkstra' |
uniqueEmail() |
An address unique within this Faker. |
sentence({words = 6}) |
'alpha beta gamma delta epsilon zeta.' |
number({min = 0, max = 1000}) |
An int, both bounds inclusive. |
boolean() |
A bool. |
dateTime() |
A UTC instant in the few years after 2020. |
uuid() |
A version-4-shaped UUID. Seeded, so not cryptographically random. |
Fakeris minimal on purpose. A dependency ofseshat_maatships into every production application, and Dart has no dev-only split for a library’s consumers. When you want richer data, add thefakerpackage to your owndev_dependenciesand call it insidedefinition()— the framework never needs to know.
uniqueEmailis unique because of a counter, not the random suffix.uuidhas no counter: twoFakers built from the same seed return the same first UUID, so do not seed two of them alike and write their UUIDs into one unique column.
Testing Console Commands
Sesh takes its output sinks, so commands are testable without touching the terminal:
test('mail:send reports what it sent', () async { final out = StringBuffer(); final maat = Sesh(app, out: out, commands: [SendEmails()]);
expect(await maat.run(['mail:send', 'ada']), 0); expect(out.toString(), contains('Sending mail for ada'));});Assert on the exit code as well as the output — it is the command’s contract with CI.
Testing Validation Directly
For rules that do not need a request:
final validator = Validator.make({'email': 'nope'}, {'email': 'required|email'});
expect(validator.fails(), isTrue);expect(validator.errors['email'], contains('The email field must be a valid email address.'));