Skip to content

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

Use a separate database. It is emptied and migrated for the tests. In .env:

database.tests.hostname = 127.0.0.1
database.tests.database = myapp_test
database.tests.username = myapp
database.tests.password = …
database.tests.DBDriver = MySQLi
database.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 of service('tenantContext'); switch with set() to test that a second tenant does not see the data.
  • Audit log: every write is logged, also in tests. Assert on anvil_audit_log where 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 actor system/cli unless a test sets one: Services::injectMock('actorResolver', new FixedActorResolver(Actor::user($userId, 'jane'))) (Anvil\Security\Audit).

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);

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.

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\Scanner passed to FileStore (return false for “infected”, throw a RuntimeException for “scan failed”).

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()).

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.

The audit chain can be checked in CI or monitoring:

Terminal window
php spark anvil:audit:verify # exit code 1 if the log was changed

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 for load.
  • 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:sendAbort in the console).
  • Use a separate database or tenant for browser tests; they write real data and audit entries.