Repository navigation
Testing Capabilities
A capability is the one place where your app's rules live, so it is the first thing to test. A test calls it the way a page, REST, the JS client or an agent does, with its permission checks, its validation and its audit, as a user, a visitor, a token or the command line, and checks what it answers or how it refuses. Since 2.18.0.
/caligrafy-quill:new-capability write the tests of create_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 with $this->call(...) and
$this->assertCapabilityFails(...), and runs php caligrafer.php test <Name>.
Check before you say OK:
- every refusal the capability can give has its assertion (
invalid,unauthenticated,forbidden,not_found,conflict); - each test creates the rows it needs;
-
php caligrafer.php testends withOK (...).
The tests run on a database of their own: set it up once as The test database shows. Then:
docker compose exec dev-box php caligrafer.php test make Bookshelfand make tests/BookshelfTest.php call the capabilities of the Bookshelf (create_book needs someone signed in or a
token, delete_book has level: 1, get_book is public):
<?php
use Caligrafy\Database;
use Caligrafy\Testing\TestCase;
final class BookshelfTest extends TestCase
{
private function author(): int
{
// a row for this test only: the test's transaction is rolled back at its end
return (int) Database::table('authors')->insert(array('name' => 'Ursula K. Le Guin'));
}
public function testAReaderAddsABook(): void
{
$book = $this->call('create_book', array('title' => 'Tehanu', 'published_year' => 1990, 'author_id' => $this->author()), $this->user(7));
$this->assertSame('Tehanu', $book['title']);
$this->assertSame('Ursula K. Le Guin', $book['author']['name']);
}
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');
}
}docker compose exec dev-box php caligrafer.php test BookshelfOK (2 tests, 7 assertions)
(PHPUnit's header, the PHP version and the time it took are printed too.)
$this->call($capability, $input, $as) builds the caller from $as:
$as |
The capability is called by | It may call |
|---|---|---|
null, or nothing |
the command line (Context::cli()) |
everything: full access |
'guest' |
a visitor over REST (Context::guest('rest')) |
only public: true capabilities |
$this->user(7) |
the user 7 over REST, level 0 | what a signed-in user of that level may call |
$this->user(7, 1) |
the user 7, level 1 | also the capabilities with level: 1
|
| an array or an object | that user (id, permissions and any attribute the capability reads) |
as above |
a Caligrafy\Context
|
exactly that context | what it allows |
$this->user($id = 1, $level = 0, $attributes = []) is a plain object: id, the level under
Context::$levelAttribute (permissions by default), and the attributes you add
($this->user(7, 0, ['username' => 'sam@example.com'])). It is not read from the users table.
As a token. A token's scopes are checked only when the caller is a token. Create one in the test (it is rolled back with the rest), then call as it:
use Caligrafy\Context;
use Caligrafy\Tokens;
public function testATokenNeedsItsScope(): void
{
$readOnly = Tokens::verify(Tokens::create('reports', array('books:read'))['token']);
$this->assertCapabilityFails('forbidden', 'create_book', array('title' => 'Tehanu', 'published_year' => 1990, 'author_id' => 1), Context::forToken($readOnly, 'mcp'));
}This needs the framework tables in the test database (platform install --env .env.testing).
$this->assertCapabilityFails($kind, $capability, $input = [], $as = null) passes when the capability throws a
Caligrafy\ServiceError of that kind, and returns it:
| Kind | Thrown when | Check also |
|---|---|---|
invalid |
an input breaks a rule of $rules, or ServiceError::invalid([...])
|
$error->fields: one message per field |
unauthenticated |
nobody is signed in and the capability is not public | |
forbidden |
the level or the scope is missing, or ServiceError::forbidden()
|
|
not_found |
ServiceError::notFound(), or a capability this caller cannot reach (a web-only one) |
$error->getMessage() |
conflict |
ServiceError::conflict('...'): the input is valid, the state refuses it |
$error->getMessage() |
A capability that does not fail makes the test fail with <capability> did not fail (expected '<kind>'); one that
fails with another kind says which: <capability> failed with 'forbidden' (...), not 'invalid'. What each kind becomes on
each channel is in Caligrafy\ServiceError.
A fake. A capability that asks the container for a service in its constructor (a clock, a mailer, a payment gateway) gets your object in this test:
$this->fake(Clock::class, new class implements Clock {
public function today(): DateTimeImmutable
{
return new DateTimeImmutable('2026-11-01');
}
});
$loan = $this->call('lend_book', array('book_id' => $bookId, 'borrower' => 'Ana'), $this->user(7));
$this->assertSame('2026-11-15', $loan['due_on']);The container's bindings are reset before each test, so a fake never leaks into the next one.
A background job. $this->assertQueued($capability, $where = null) passes when a job of that capability is pending
in caligrafy_jobs (pushed in this test, since the table is rolled back), and returns its input:
// a capability whose handle() runs Queue::push('send_welcome_email', ['user_id' => $id]) was called above
$input = $this->assertQueued('send_welcome_email', fn ($input) => $input['user_id'] === 7);The job does not run in the test: call its capability yourself to test what it does (Background jobs).
Helper of Caligrafy\Testing\TestCase
|
What it does |
|---|---|
$this->call($capability, $input = [], $as = null) |
calls it through Capabilities::call(): checks, validation, audit; returns its answer |
$this->assertCapabilityFails($kind, $capability, $input = [], $as = null) |
the capability refuses with $kind; returns the ServiceError
|
$this->user($id = 1, $level = 0, $attributes = []) |
a signed-in user for $as
|
$this->fake($id, $object) |
the container gives $object for $id in this test |
$this->assertQueued($capability, $where = null) |
a job of the capability is pending; returns its input |
$this->action(...), $this->render(...)
|
web-only capabilities and views: Testing pages, forms and actions |
protected $transactions = true |
false runs the test without a transaction (The test database) |
Before each test, the container is reset and booted, the cache's in-memory copies and the query shapes of the N+1 warning are forgotten. The capability registry itself is in the API reference.
- A test that counts on your rows: it passes or fails with the data you have. Each test creates its rows.
-
$this->call()on a web-only capability: it answersnot_found, as every other channel does. Use$this->action(). -
A test that reads
$_SESSIONor the request: a plain capability never does; give the user with$this->user(). -
A token without
--userpasses anylevel(G140): test administrator capabilities with a user of the right level and with a token, both. -
A custom validator of
config.php(G141): the tests, like the command line, do not loadapplication/config/config.php.
- Testing your app
- Testing pages, forms and actions · The test database
- Dependency injection
- 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.
Why Caligrafy: the benefits, the measurements, and what it is not yet
-
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
-
#[Capability]: every option - 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
- Views and page components
- Design styles: another look, one setting
- Declarative routes: every request, in one line
-
Route::page·Route::form·Route::auth·Route::webhook·Route::download·Route::redirect·Route::action·Route::snap· capability routes - How to export a list as CSV
- Controllers: moving one to a declared route · how to, step by step
- Forms and helpers
-
Vue apps
-
Mobile and tablet
-
REST API
-
AI agents
-
Snap Cards and the assistant
- 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
-
API reference: every class and method
-
Routing ·
Route·AuthRoutes· pages ·Controller· capabilities -
Data ·
Model·Query· sign-in · jobs ·Notify· payments · AI -
Interfaces · Snap ·
Snap\Cards·Snap\Conversation· testing · tooling · helper functions
-
Routing ·
-
Caligrafer: every command
- Project · build · data · agents · jobs and tests · maintenance · deploy
- Installation, configuration and Caligrafer
- Routing · Request · Controllers · Views
- Vue: flow of information · routes · validations
- Security and upgrading
- Release notes: 2.26 · 2.25 · 2.24 · 2.23 · 2.22 · 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)