Repository navigation
Testing Your App
Your app's tests check what it does through its capabilities, the way its pages, REST API, Vue apps and AI agents reach them: with the permission checks, the validation and the audit. Each test runs in a database transaction that is rolled back at its end, so tests leave the database as they found it. Since 2.18.0.
The new-capability skill writes a test with each capability. To test what exists, ask:
/caligrafy-quill:new-capability write the tests of create_book, get_book and lend_book: the rule cases as a user, the refusals as a guest
Claude runs php caligrafer.php test make <Name>, fills tests/<Name>Test.php (extends Caligrafy\Testing\TestCase)
with $this->call(...) and $this->assertCapabilityFails(...), and runs php caligrafer.php test <Name>.
Check before you say OK:
- every refusal kind the capability can give has its assertion (
invalid,unauthenticated,forbidden,not_found,conflict); - the tests create their own rows: they do not count on the rows of your development database;
-
php caligrafer.php testends withOK (...).
The tests of the Bookshelf capabilities (create_book, list_books, get_book, delete_book of
Build once
and lend_book of Dependency injection, on
the loans table of Models). create_book is closed
there (signed in or a token) and delete_book has level: 1.
PHPUnit comes with the development packages of every project (composer install). Run the commands in the Docker
container (docker compose exec dev-box php caligrafer.php ...; without Docker, drop docker compose exec dev-box).
-
A database for the tests. Copy the settings:
cp .env .env.testing. In.env.testing, change one line:DB_DATABASE=caligrafy_test.env.testingholds the same secrets as.env: the project's.gitignorealready leaves it out of git (every.env.*file but.env.example, 2.19.1). Then create the database and its tables:docker compose exec mysql mysql -uroot -proot -e "CREATE DATABASE IF NOT EXISTS caligrafy_test" docker compose exec dev-box php caligrafer.php platform install --env .env.testing docker compose exec dev-box php caligrafer.php sql application/models/Bookshelf/create_db.sql --env .env.testing docker compose exec dev-box php caligrafer.php migrate --env .env.testing
--env .env.testingruns the command on the test database (application/models/Bookshelf/create_db.sql run on caligrafy_test,1 migration(s) run on caligrafy_test). The tests read.env, then.env.testing, whose settings win. -
Create the test file. Run:
docker compose exec dev-box php caligrafer.php test make Bookshelf
tests/BookshelfTest.php written: php caligrafer.php test Bookshelf -
Write the tests. Make
tests/BookshelfTest.phpread:<?php use Caligrafy\Database; use Caligrafy\Testing\TestCase; /** * php caligrafer.php test Bookshelf */ final class BookshelfTest extends TestCase { private function author(): int { // a row for this test only: the transaction of the test is rolled back at its end return (int) Database::table('authors')->insert(array('name' => 'Ursula K. Le Guin')); } public function testAReaderAddsABookAndEveryoneSeesIt(): void { $authorId = $this->author(); $book = $this->call('create_book', array('title' => 'Tehanu', 'published_year' => 1990, 'author_id' => $authorId), $this->user(7)); $this->assertSame('Tehanu', $book['title']); $this->assertSame('Ursula K. Le Guin', $book['author']['name']); $page = $this->call('list_books', array('author_id' => $authorId), 'guest'); $this->assertSame(1, $page['total']); $this->assertSame('Tehanu', $page['data'][0]['title']); } public function testRefusals(): void { $error = $this->assertCapabilityFails('invalid', 'create_book', array('title' => 'Tehanu'), $this->user(7)); $this->assertArrayHasKey('published_year', $error->fields); $this->assertCapabilityFails('unauthenticated', 'create_book', array('title' => 'Tehanu'), 'guest'); $this->assertCapabilityFails('forbidden', 'delete_book', array('id' => 1), $this->user(7, 0)); $this->assertCapabilityFails('not_found', 'get_book', array('id' => 999999), 'guest'); } public function testALoanIsDueIn14Days(): void { $this->fake(Clock::class, new class implements Clock { public function today(): DateTimeImmutable { return new DateTimeImmutable('2026-11-01'); } }); $book = $this->call('create_book', array('title' => 'Tehanu', 'published_year' => 1990, 'author_id' => $this->author())); $loan = $this->call('lend_book', array('book_id' => $book['id'], 'borrower' => 'Ana'), $this->user(7)); $this->assertSame('2026-11-15', $loan['due_on']); // 14 days after the fixed today $this->assertCapabilityFails('conflict', 'lend_book', array('book_id' => $book['id'], 'borrower' => 'Ben'), $this->user(7)); } public function testTheBookPage(): void { // the data Route::page gives application/views/Bookshelf/book.pug: get_book and list_authors $html = $this->render('Bookshelf/book', array( 'book' => array('id' => 1, 'title' => 'Tehanu', 'published_year' => 1990, 'summary' => 'The fourth Earthsea book.', 'isbn' => null, 'author_id' => 1, 'author' => array('name' => 'Ursula K. Le Guin')), 'authors' => array(array('id' => 1, 'name' => 'Ursula K. Le Guin')) )); $this->assertStringContainsString('Tehanu', $html); $this->assertStringContainsString('by Ursula K. Le Guin, 1990', $html); } }
$this->user(7)is a signed-in user of id 7 and level 0:delete_book(level 1) refuses them.$this->call(..., null)(no third argument) is the command line, with full access. -
Run them.
docker compose exec dev-box php caligrafer.php test
(between them, the PHP version and the time it took).PHPUnit 12.5.40 by Sebastian Bergmann and contributors. .... 4 / 4 (100%) OK (4 tests, 13 assertions)php caligrafer.php test Loanruns only the tests whose name matchesLoan(testALoanIsDueIn14Days).
Check:
-
OK (4 tests, 13 assertions); - the test database has no
Tehanuafterwards:docker compose exec mysql mysql -uroot -proot caligrafy_test -e "SELECT COUNT(*) FROM books WHERE title = 'Tehanu'"gives0(the five sample books ofcreate_db.sqlstay); - break a rule on purpose (remove
requiredfrompublished_yearinCreateBook):testRefusalsfails. Put it back.
| Helper | What it does |
|---|---|
$this->call($capability, $input, $as) |
calls the capability with its checks, validation and audit; returns its answer as plain data |
$this->user($id = 1, $level = 0, $attributes = []) |
a signed-in user for $as: an id, a permission level (permissions), other attributes the capability reads |
$this->action($capability, $input, $as) |
calls a capability declared webOnly: true as Route::action does (2.21.0); returns its answer: a Caligrafy\Web, whose ->describe() gives type, target, data, status and headers, or plain data |
$this->assertCapabilityFails($kind, $capability, $input, $as) |
the capability refuses with $kind (invalid, unauthenticated, forbidden, not_found, conflict); returns the ServiceError (->fields, ->getMessage()) |
$this->render($view, $data) |
the HTML of a Pug view of application/views, as a page shows it |
$this->fake($class, $object) |
the container gives $object for $class in this test |
$this->assertQueued($capability, $where = null) |
a background job of that capability was pushed in this test (whose input passes the function $where); returns its input |
Who calls ($as)
$as |
The test calls as |
|---|---|
null (or nothing) |
the command line: full access |
'guest' |
a visitor: only public capabilities |
$this->user(7), an object or an array |
that user, over REST |
a Caligrafy\Context
|
that context (a token: Context::forToken($row, 'mcp')) |
Commands
| Command | What it does |
|---|---|
php caligrafer.php test make <Name> |
writes tests/<Name>Test.php (refuses an existing file) |
php caligrafer.php test |
runs every tests/*Test.php and tests/*/*Test.php with PHPUnit; exit 0 when they pass |
php caligrafer.php test <filter> |
only the tests whose class or method name matches |
Without PHPUnit it says PHPUnit is not installed: composer install (with the development packages), then php caligrafer.php test.
A warning fails the run (--fail-on-warning).
- With
DB_ACTIVATE=true, each test runs in a transaction that is rolled back at its end: the rows a test writes are gone after it, and tests never see each other's rows. - The settings are those of
.env, then of.env.testing(whose settings win).DB_TEST_DATABASE=caligrafy_testin.envalso works: it replacesDB_DATABASEfor the tests only; the commands that prepare that database still need a settings file (--env). - A test that must not run in a transaction sets
protected $transactions = false;(and cleans up after itself). - Between tests, the container's bindings, the cache's in-memory copies and the counts of the N+1 warning are reset.
tests/ at the top of the project, one class per file named *Test.php, extending Caligrafy\Testing\TestCase.
The production image leaves tests/ out, and the framework never loads it on a page.
-
A
CREATE,ALTERorDROPin a test (G121): MySQL commits it and ends the test's transaction, so the rows of the test stay. Create tables increate_db.sqland migrations, before the tests. -
Tests on your development database: they pass or fail with the rows you have. Give them
.env.testing. -
.env.testingin git: it holds your secrets. The project's.gitignoreleaves out every.env.*but.env.example(2.19.1); a project made before 2.19.1 adds/.env.*and!/.env.exampleto its.gitignore. -
migrate "" --env .env.testing: the empty""was needed before 2.19.1. Since 2.19.1, writemigrate --env .env.testing. -
A test that reads
$_SESSIONor the request: a plain capability never does; give the user with$this->user(). -
$this->call()on a web-only capability: it answersnot_found, as every other channel. Use$this->action(). It runshandle()without a request: a capability that callsrequest()or signs someone in is tested through its parts, and in the browser.
- Build once: capabilities
- Dependency injection: fakes
-
Background jobs:
assertQueued() - Testing Caligrafy: the framework's own QA suite
- Caligrafer: test
Caligrafy: Describe it. Approve it. Ship it. Your idea turned into a complete app: every feature built once, for web pages, Vue apps, a REST API and AI agents, and online.
-
Quickstart with Claude: describe it, approve it, ship it
or the Quickstart: the same three steps by hand, in 5 minutes
- How Caligrafy works: the ideas in 5 minutes
- Your first capability: build a feature, page included
-
Capabilities in depth
- Scheduled jobs: capabilities on a timer
- Notifications: web push and email
- Background jobs: capabilities run later
- Migrations: database changes, in order
- The cache
- Testing your app
- Dependency injection
- Validation rules
-
Web pages
-
Vue apps
-
Mobile and tablet
-
REST API
-
AI agents
- Models
- Relationships
- Queries: filters, paging, related rows
- Sign-in, users and tokens · with an email
- Payments: Stripe and crypto
- Face detection and face login
- OpenAI: chat and images · Assistants
- Open models: Llama, Mistral...
- Chatbots with Watson
- Machine learning in the browser
- Metadata and social cards · search and analytics
-
Putting it online: the production image, the database at every start
- Caligrafer: every command
- Installation, configuration and Caligrafer
- Routing · Request · Controllers · Views
- Vue: flow of information · routes · validations
- Security and upgrading
- Release notes: 2.21 · 2.20 · 2.19 · 2.18 · 2.17 · 2.16 · 2.15 · 2.14 · 2.13 · 2.12 · 2.11 · 2.10 · 2.9 · 2.8 · 2.7 · 2.6 · 2.5 · 2.4 · 2.3 · 2.1 · October 2026 · changelog
- Testing Caligrafy (contributors)