Testing
Anvil applications are tested with PHPUnit and CodeIgniter’s test tools.
Anvil adds two helpers in Anvil\Test:
| Helper | Purpose |
|---|---|
InteractsWithAnvil (trait) |
Grant permissions, send component actions as the browser would, fake uploads, assert rejections |
TestUploadedFile |
Uploaded file that is valid outside of a real upload |
Test database
Section titled “Test database”Use a separate database. It is emptied and migrated for the tests. In
.env:
database.tests.hostname = 127.0.0.1database.tests.database = myapp_testdatabase.tests.username = myappdatabase.tests.password = …database.tests.DBDriver = MySQLidatabase.tests.DBPrefix = db_A base class for database tests:
namespace Tests\Support;
use Anvil\Tenancy\TenantModel;use Anvil\Test\InteractsWithAnvil;use CodeIgniter\Test\CIUnitTestCase;use CodeIgniter\Test\DatabaseTestTrait;
abstract class AppTestCase extends CIUnitTestCase{ use DatabaseTestTrait; use InteractsWithAnvil;
protected $namespace = null; // migrations of the app, Anvil, Shield, Settings and Queue protected $refresh = true;
protected int $tenantId;
protected function setUp(): void { // Shared services (gate, dispatcher, …) must not leak between tests. $this->resetServices();
parent::setUp();
$tenants = new TenantModel(); $this->tenantId = (int) $tenants->insert(['slug' => 'acme', 'name' => 'ACME', 'is_active' => 1]); service('tenantContext')->set($this->tenantId); }}- Tenants: tenant scoped tables have a foreign key to
anvil_tenants, so create the tenant before inserting records. Models read and write in the tenant ofservice('tenantContext'); switch withset()to test that a second tenant does not see the data. - Audit log: every write is logged, also in tests. Assert on
anvil_audit_logwhere the log is part of the requirement, e.g.seeInDatabase('anvil_audit_log', ['event' => 'invoice.sent']). Tests run on the command line, so entries name the actorsystem/cliunless a test sets one:Services::injectMock('actorResolver', new FixedActorResolver(Actor::user($userId, 'jane')))(Anvil\Security\Audit).
Users and permissions
Section titled “Users and permissions”Two ways, depending on what the test is about:
| Test | Use |
|---|---|
| The business logic of a resource, form or action | grantPermissions(['invoices.create']): the current user has exactly these permissions, no roles needed |
| Roles, the role editor, the permission check itself | A real user with a role and actingAs($user) (Shield’s AuthenticationTesting) |
grantPermissions() replaces the permission checker with an
ArrayPermissionChecker and resets the gate and the dispatcher;
policies of Config\Anvil::$policies
still apply. Actions that need
a logged in user (e.g. “only for users”, the author of a file, saved
views) also need actingAs($user):
use CodeIgniter\Shield\Entities\User;use CodeIgniter\Shield\Models\UserModel;use CodeIgniter\Shield\Test\AuthenticationTesting;
$users = model(UserModel::class);$user = new User(['username' => 'jane', 'email' => 'jane@example.com', 'password' => 'Secret-Jane-2026!', 'active' => 1]);$users->save($user);$user = $users->findById($users->getInsertID());
service('tenantRepository')->addMember($this->tenantId, (int) $user->id);$this->actingAs($user);Forms, lists and actions
Section titled “Forms, lists and actions”The panel pages render components; their actions go to the
component endpoint.
dispatchComponent($panel, $component, $action, $input, $record, $context)
sends an action like the browser: with an encrypted token for the
component, the tenant of service('tenantContext') and the record (UUID).
Everything in between (token check, permissions, validation, the
transaction, the audit log) runs as in production; the answer is the
ComponentResponse (html, status, events, redirect, download).
final class InvoiceResourceTest extends AppTestCase{ public function testCreate(): void { $this->grantPermissions(['invoices.create']);
$response = $this->dispatchComponent('admin', 'invoices:form', 'save', [ 'number' => 'RE-2026-001', 'amount' => '1250', // NumberInput reads the locale format, e.g. "1.250,50" in de ]);
$this->assertNotNull($response->redirect); $this->seeInDatabase('invoices', ['number' => 'RE-2026-001', 'amount' => '1250.00', 'tenant_id' => $this->tenantId]); }
public function testUpdate(): void { $this->grantPermissions(['invoices.update']); $uuid = $this->createInvoice('RE-1');
$this->dispatchComponent('admin', 'invoices:form', 'save', ['number' => 'RE-2'], $uuid);
$this->seeInDatabase('invoices', ['number' => 'RE-2']); }
public function testCreateRequiresPermission(): void { $this->grantPermissions(['invoices.view']);
$this->assertRejected(403, fn () => $this->dispatchComponent('admin', 'invoices:form', 'save', ['number' => 'RE-1'])); }
public function testNumberIsRequired(): void { $this->grantPermissions(['invoices.create']);
$e = $this->assertRejected(422, fn () => $this->dispatchComponent('admin', 'invoices:form', 'save', ['number' => '']));
$this->assertArrayHasKey('number', $e->errors); }}Component ids are <resource slug>:<kind> within the panel:
| Kind | Actions |
|---|---|
form |
save, delete, act-<action> (record actions), lookup-save (search of a searchable BelongsTo, input q and field) |
collection |
load (search, filters, sort and page are its input), layout (column choice), export, delete, bulkDelete, act-<action> (row actions, input record), bulk-<action> (input records), saveView, applyView, deleteView, defaultView |
attachments |
upload, delete (input file) |
Pages with components use <page slug>:<kind>. Related lists pass the
relation in $context. Record actions, relation lists and pages of your own
work the same way; the ids and actions are those your component or resource
defines.
Rejections and their statuses:
| Status | Meaning |
|---|---|
| 400 | Malformed request |
| 403 | No permission, a policy denies the record, invalid or expired token, a token of another session, panel or tenant |
| 404 | Unknown component or action; record not found, deleted, or in another tenant |
| 422 | Validation failed ($e->errors by field) |
assertRejected() returns the EndpointException; $e->reason holds the
internal reason (e.g. permission, tenant mismatch).
Test the tenant boundary of every resource once: create a record in one tenant, switch the tenant, and expect 404.
Uploads
Section titled “Uploads”File fields and attachments read the uploads of the request. In tests,
fakeUploads() gives them files instead:
use Anvil\Test\TestUploadedFile;
$pdf = file_get_contents(SUPPORTPATH . 'files/contract.pdf');
$this->grantPermissions(['contracts.update']);$this->fakeUploads('admin', 'contracts', [ 'document' => TestUploadedFile::withContent('contract.pdf', $pdf), // FileUpload field 'files' => [TestUploadedFile::withContent('appendix.pdf', $pdf)], // attachments]);
$this->dispatchComponent('admin', 'contracts:attachments', 'upload', [], $uuid);$this->seeInDatabase('anvil_files', ['name' => 'appendix.pdf']);- Use real file content: the type is detected from the content and must match the extension.
- Store files of tests in their own directory:
Services::injectMock('files', new FileStore(new LocalStorage(WRITEPATH . 'test-files'))). - A virus scanner is tested with a fake
Anvil\Files\Scannerpassed toFileStore(returnfalsefor “infected”, throw aRuntimeExceptionfor “scan failed”).
Panel pages
Section titled “Panel pages”Pages are tested with CodeIgniter’s FeatureTestTrait and a logged in
user:
use CodeIgniter\Test\FeatureTestTrait;
$this->actingAs($user);
$result = $this->get('admin/invoices');
$result->assertOK();$result->assertSee('RE-2026-001');Assert that pages without permission are rejected (assertStatus(403)),
and that the HTML contains UUIDs, never internal ids.
API tests use FeatureTestTrait with a client and a token of the current
tenant. The routes exist when Config\Anvil::$apiPanels or
$apiEndpoints is set:
$created = service('apiClients')->create('Billing system');service('apiClients')->setPermissions($created['client'], ['invoices.view']);$token = service('apiClients')->issueToken($created['client'], 'test');
$result = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get('api/v1/invoices');
$result->assertOK();$this->seeInDatabase('anvil_api_access_log', ['status' => 200]);After changing the API configuration in a test, reset the services
apiRegistry and apiKernel (Services::resetSingle()).
Queued exports and jobs
Section titled “Queued exports and jobs”Large exports go to the queue (queue_jobs). In a test, do what the worker
does:
config(\Config\Anvil::class)->exportSyncLimit = 1;
$this->dispatchComponent('admin', 'invoices:collection', 'export', ['format' => 'csv']);
$job = db_connect()->table('queue_jobs')->where('queue', 'anvil')->get()->getRowArray();service('exporter')->runQueued(json_decode($job['payload'], true)['data']);
$this->seeInDatabase('anvil_exports', ['status' => 'done']);The job runs as the user who requested the export and checks the permission again.
Commands
Section titled “Commands”The audit chain can be checked in CI or monitoring:
php spark anvil:audit:verify # exit code 1 if the log was changedBrowser tests
Section titled “Browser tests”End to end tests (e.g. Playwright) need a few things:
- Pages are loaded with htmx boost: wait with
waitUntil: 'commit'and then for an element of the new page, not forload. - Run the tests one after the other, not in parallel: they share a session
and the rate limit of the endpoint (
$endpointRateLimit). - Do not navigate while the login redirect is running; htmx aborts the
request (
htmx:sendAbortin the console). - Use a separate database or tenant for browser tests; they write real data and audit entries.