Skip to content

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-token

Each 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 exist
response.assertJsonPath('author.name', 'Ada'); // dot notation, through lists too
response.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 all
response.assertJsonMissingValidationErrors(['title']); // not these ones

Headers 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; // String
response.headers;
response.json; // decoded

A 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.

migrate belongs in setUpAll, not setUp. The connection is late 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. Connection exposes only the callback form transaction(body), and a callback scope cannot span package:test’s separate setUp / body / tearDown calls, so truncate deletes rows instead. The observable behaviour is the same, including ids restarting at 1.

dart test isolates per file, not per test. Anything static — a ModelBinding registration, 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 in setUp or tearDown, or a suite that passes today fails under --test-randomize-ordering-seed=random tomorrow.

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:

Terminal window
maat make:factory PostFactory
maat make:factory AuthorFactory --model=User

make:model Post -f --fields "title:string published:bool" writes the model and a factory with a faker value per field.

state and count are abstract, and yours must return a NEW factory. Laravel clones $this reflectively; Dart cannot, so only your subclass can build another of itself. An implementation that mutates and returns this leaks 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 written
final posts = PostFactory().count(3).make(); // three, in memory
final saved = await PostFactory().createOne(); // inserted, with its key
final many = await PostFactory().count(3).create(); // three rows

createOne 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. for is a Dart keyword, so it cannot be a method name — belongsTo is 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.

has returns a Factory<T>, not your subclass, so apply your named states before it: UserFactory().inactive().has(...), never UserFactory().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.

Faker is minimal on purpose. A dependency of seshat_maat ships into every production application, and Dart has no dev-only split for a library’s consumers. When you want richer data, add the faker package to your own dev_dependencies and call it inside definition() — the framework never needs to know.

uniqueEmail is unique because of a counter, not the random suffix. uuid has no counter: two Fakers 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.'));