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';
voidmain() {
lateApplication app;
setUp(() async {
app =awaitcreateApp();
});
tearDown(() =>Application.reset());
test('creates a post through the HTTP boundary', () async {
final response =awaitTestClient(app).postJson('/api/posts', {
Every method accepts per-request headers as its last argument.
TestClient always dispatches with ip: '127.0.0.1' and has no way to
override it — every send goes through Request.create(...) without an
ip argument, so the default takes over. A test whose behaviour depends on
the client’s address (an IP allowlist, a loopback-only route) proves nothing
through TestClient, whichever IP the guard expects — it always sees
127.0.0.1. Build the Request directly and dispatch it through the kernel
instead:
final request =Request.create(path:'/admin', ip:'10.0.0.9');
final response =await app.make<HttpKernel>().handle(request);
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.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.
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:
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:
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';
voidmain() {
final db =RefreshDatabase(migrations: [CreatePostsTable()]);
setUpAll(db.migrate);
tearDown(db.truncate);
tearDownAll(db.close);
test('a post can be created', () async {
final post =awaitPostFactory().createOne();
expect(awaitPost.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.
PostFactory(faker: faker, count: n, states: states);
PostFactorydraft() =>state({'published':false});
}
Generate the skeleton with:
Terminal window
maatmake:factoryPostFactory
maatmake:factoryAuthorFactory--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 =awaitPostFactory().createOne(); // inserted, with its key
final many =awaitPostFactory().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:
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.'));