Testing & E2E
This document covers how to test Tagixo plugin integrations, custom modules, PropTypes, and full render pipelines — both with Pest (PHP) and Playwright (E2E).
What the plugin covers vs. what you test
| Area | Plugin provides | You test |
|---|---|---|
| Custom modules | registerModule() contract |
Correct HTML output, _styles CSS, empty-guard |
| PropTypes | toCss() / toHtml() |
Enabled/disabled branches, merged output |
| Props | Schema definition | ->default() feeds drawer; render uses payload |
| Hooks | afterFormSubmit, beforeRender, etc. |
Side-effects (mail sent, DB row created) |
| Form modules | Field rendering, validation | Submission happy-path and error state |
| Integration glue | AdminPanelProvider, seeder |
Resource loads, page slug resolves |
| Builder API | REST endpoints (/tagixo/builder/*) |
Auth, payload shape, persistence |
TagixoTestCase
Ccast\Tagixo\Testing\TagixoTestCase ships inside ccast/tagixo and is available in your vendor/ after composer install. It boots the plugin's service provider, module registry, PropType registry, and Prop registry in isolation — no full HTTP stack, no DB seeding required.
| Method | Description |
|---|---|
renderPage(Page $page): string |
Runs the full PageRenderer::renderWithLayout() pipeline and returns the final HTML string |
makePage(array $attrs = []): Page |
Factory: creates and persists a tgx_pages row with sane defaults |
makeLayout(array $attrs = []): Layout |
Factory: creates a tgx_layouts row |
makeFormSchema(array $attrs = []): FormSchema |
Factory: creates a tgx_forms row |
makeMailTemplate(array $attrs = []): MailTemplate |
Factory: creates a tgx_mail_templates row |
makeGlobalVariable(array $attrs = []): GlobalVariable |
Factory: creates a tgx_global_variables row |
The standard Laravel TestCase base is included, so fake(), actingAs(), Mail::fake(), HTTP assertions, and all other Laravel test utilities are available.
PHP / Pest integration tests
Module rendering
use Ccast\Tagixo\Testing\TagixoTestCase;
use App\Tagixo\Modules\CalloutModule;
uses(TagixoTestCase::class);
it('renders callout title', function () {
$page = $this->makePage([
'content' => json_encode([
'type' => 'section',
'children' => [[
'type' => 'row',
'children' => [[
'type' => 'column',
'children' => [[
'type' => 'module',
'module' => 'callout',
'props' => ['title' => ['text' => 'Important notice']],
]],
]],
]],
]),
]);
$html = $this->renderPage($page);
expect($html)
->toContain('Important notice')
->toContain('data-module="callout"');
});
it('applies accent colour from _styles', function () {
$page = $this->makePage([
'content' => json_encode([
'type' => 'section',
'_styles' => [
'elementMain' => [
'color' => ['value' => '#ff0000', 'enabled' => true],
],
],
'children' => [],
]),
]);
$html = $this->renderPage($page);
expect($html)->toContain('color:#ff0000');
});
it('does not render when title is empty and hide_empty is enabled', function () {
$page = $this->makePage([
'content' => json_encode([
'type' => 'section',
'children' => [[
'type' => 'row',
'children' => [[
'type' => 'column',
'children' => [[
'type' => 'module',
'module' => 'callout',
'props' => ['title' => ['text' => ''], 'hide_empty' => true],
]],
]],
]],
]),
]);
expect(fn () => $this->renderPage($page))->not->toThrow(\Throwable::class);
});
PropType toCss()
use App\Tagixo\PropTypes\GradientBorderPropType;
it('emits linear-gradient border when enabled', function () {
$pt = new GradientBorderPropType();
$css = $pt->toCss([
'enabled' => true,
'color_a' => '#ff0000',
'color_b' => '#0000ff',
'width' => 2,
]);
expect($css)
->toContain('linear-gradient')
->toContain('border-width:2px');
});
it('returns empty string when disabled', function () {
$pt = new GradientBorderPropType();
$css = $pt->toCss(['enabled' => false]);
expect($css)->toBe('');
});
Full render pipeline via renderPage()
use Ccast\Tagixo\Testing\TagixoTestCase;
uses(TagixoTestCase::class);
it('runs the section → row → column → module hierarchy end-to-end', function () {
$layout = $this->makeLayout([
'header' => json_encode(['type' => 'section', 'children' => []]),
'footer' => json_encode(['type' => 'section', 'children' => []]),
]);
$page = $this->makePage([
'layout_id' => $layout->id,
'content' => json_encode([
'type' => 'section',
'children' => [[
'type' => 'row',
'children' => [[
'type' => 'column',
'children' => [[
'type' => 'module',
'module' => 'text',
'props' => ['content' => '<p>Hello world</p>'],
]],
]],
]],
]),
]);
$html = $this->renderPage($page);
expect($html)
->toContain('Hello world')
->toContain('tgx-section')
->toContain('tgx-row')
->toContain('tgx-column');
});
Form hook testing
use Illuminate\Support\Facades\Notification;
use App\Notifications\NewContactSubmission;
use App\Tagixo\Hooks\NotifyAdminOnSubmit;
it('dispatches admin notification on form submit', function () {
Notification::fake();
config(['tagixo.forms.admin_email' => 'admin@example.com']);
(new NotifyAdminOnSubmit())->handle(
['name' => 'Jane', 'email' => 'jane@example.com', 'message' => 'Hello'],
'contact',
);
Notification::assertSentOnDemand(
NewContactSubmission::class,
fn ($n, $channels, $notifiable) => $notifiable->routes['mail'] === 'admin@example.com',
);
});
it('does not dispatch when form key does not match', function () {
Notification::fake();
(new NotifyAdminOnSubmit())->handle(
['email' => 'x@example.com'],
'newsletter', // wrong form key
);
Notification::assertNothingSent();
});
Snapshot testing
use Ccast\Tagixo\Testing\TagixoTestCase;
uses(TagixoTestCase::class);
it('renders the Home page identically to the last snapshot', function () {
$page = $this->makePage(['slug' => 'home', 'status' => 'published']);
$html = $this->renderPage($page);
expect($html)->toMatchSnapshot();
});
Caution: do not run
--update-snapshotsin bulk without reviewing the diff. A snapshot update should match an intentional change, not a regression.
Factory helpers
| Method | Table | Key defaults |
|---|---|---|
makePage(array) |
tgx_pages |
slug = random, status = draft |
makeLayout(array) |
tgx_layouts |
empty header/footer |
makeFormSchema(array) |
tgx_forms |
key = random |
makeMailTemplate(array) |
tgx_mail_templates |
key = random |
makeGlobalVariable(array) |
tgx_global_variables |
key + value required |
Example — HTTP test using a factory page:
use Ccast\Tagixo\Testing\TagixoTestCase;
uses(TagixoTestCase::class);
it('resolves the page slug over HTTP and returns 200', function () {
$layout = $this->makeLayout(['name' => 'Default']);
$page = $this->makePage([
'slug' => 'about',
'status' => 'published',
'layout_id' => $layout->id,
'content' => json_encode(['type' => 'page', 'children' => []]),
]);
$this->get('/about')->assertOk();
});
Builder API tests
use Ccast\Tagixo\Testing\TagixoTestCase;
use App\Models\User;
uses(TagixoTestCase::class);
it('bootstrap endpoint returns required keys', function () {
$page = $this->makePage(['status' => 'draft']);
$user = User::factory()->create();
$this->actingAs($user)
->getJson("/tagixo/builder/bootstrap?page_id={$page->id}")
->assertOk()
->assertJsonStructure(['page', 'catalog', 'propTypes', 'globalVariables']);
});
it('save endpoint persists updated content', function () {
$page = $this->makePage(['status' => 'draft']);
$user = User::factory()->create();
$payload = [
'page_id' => $page->id,
'content' => ['type' => 'page', 'children' => []],
];
$this->actingAs($user)
->postJson('/tagixo/builder/save', $payload)
->assertOk()
->assertJsonPath('status', 'ok');
$this->assertDatabaseHas('tgx_pages', ['id' => $page->id]);
});
Playwright E2E
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.APP_URL ?? 'http://localhost',
headless: true,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
Form submission test
import { test, expect } from '@playwright/test';
test('contact form submits successfully', async ({ page }) => {
await page.goto('/contact');
await page.fill('[name="name"]', 'Ada Lovelace');
await page.fill('[name="email"]', 'ada@example.com');
await page.fill('[name="message"]', 'Hello from Playwright');
await page.click('[type="submit"]');
await expect(page.locator('.tgx-form-success')).toBeVisible();
await expect(page.locator('.tgx-form-success')).toContainText('Thank you');
});
Drag-and-drop builder test (canvas iframe)
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/admin/login');
await page.fill('[name="email"]', process.env.ADMIN_EMAIL!);
await page.fill('[name="password"]', process.env.ADMIN_PASSWORD!);
await page.click('[type="submit"]');
});
test('drags a Text module into the canvas', async ({ page }) => {
await page.goto('/admin/pages/1/edit');
// The builder canvas lives in an iframe
const canvas = page.frameLocator('#tagixo-canvas-iframe');
await canvas.locator('[data-vb-section]').waitFor();
const textTile = page.locator('[data-catalog-type="text"]');
const dropZone = canvas.locator('[data-vb-column]').first();
await textTile.dragTo(dropZone);
await expect(canvas.locator('[data-module="text"]')).toBeVisible();
});
Visual snapshot test
import { test, expect } from '@playwright/test';
test('homepage matches visual snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
threshold: 0.01,
});
});
Update baselines after intentional visual changes:
npx playwright test --update-snapshots
Visual snapshots are sensitive to font rendering differences across OS versions. Run baseline generation in the same environment used in CI, or use Docker for consistency.
CI integration
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
php-tests:
name: PHP / Pest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo, pdo_sqlite, pdo_pgsql
- name: Install Composer dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TAGIXO_PAT }}"}}'
run: composer install --no-interaction --prefer-dist
- name: Run Pest
run: vendor/bin/pest --ci
playwright-tests:
name: Playwright E2E
runs-on: ubuntu-latest
needs: php-tests
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo, pdo_sqlite
- name: Install Composer dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TAGIXO_PAT }}"}}'
run: composer install --no-interaction --prefer-dist
- name: Install Node dependencies
run: npm ci
- name: Build frontend assets
run: npm run build
- name: Prepare test database
run: |
cp .env.testing.example .env
php artisan migrate --force
php artisan db:seed --class=TestSeeder
- name: Start app
run: php artisan serve --port=8000 &
env:
APP_ENV: testing
DB_CONNECTION: sqlite
DB_DATABASE: database/testing.sqlite
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test
env:
APP_URL: http://localhost:8000
ADMIN_EMAIL: admin@example.com
ADMIN_PASSWORD: password
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
Running tests locally
# All Pest tests (via Sail)
./vendor/bin/sail composer test
# Single test file
./vendor/bin/sail artisan test --filter=CalloutModuleTest
# Pest with coverage
./vendor/bin/sail composer test -- --coverage --min=80
# Update PHP snapshot baselines
vendor/bin/pest --update-snapshots
# Playwright tests (app must be running)
npx playwright test
# Single Playwright spec
npx playwright test tests/e2e/contact-form.spec.ts
# Interactive step-through UI
npx playwright test --ui
# Update visual snapshot baselines
npx playwright test --update-snapshots