How to test pages, components, and actions

This guide shows how to scaffold unit tests against compiled .pk pages and server actions. The full API surface (every builder method, assertion, and option) lives in testing API reference. For end-to-end browser tests see browser testing harness reference.

Quick example

package pages_test

import (
    "context"
    "testing"

    "piko.sh/piko"
    customers "myapp/dist/pages/pages_customers_abc123"
)

func TestCustomersPage(t *testing.T) {
    mockRepo := &MockCustomerRepo{Customers: []Customer{{Name: "Acme"}}}
    ctx := context.WithValue(context.Background(), "repo", mockRepo)

    request := piko.NewTestRequest("GET", "/customers").Build(ctx)

    tester := piko.NewComponentTester(t, customers.BuildAST)
    view := tester.Render(request, piko.NoProps{})

    view.QueryAST(".customer-row").Count(1)
    view.QueryAST("h1").HasText("Customers")
    view.AssertTitle("Customers - MyApp")
}

Lay out test files

Place test files next to the compiled component package in the dist/ output directory:

dist/
  pages/
    pages_customers_abc123/
      component.go       // generated component
      component_test.go  // your test

Alternatively, put tests wherever fits the project, as long as the test imports the generated component package.

Build a test request

piko.NewTestRequest(method, path) returns a fluent builder. The terminating Build call takes the context, which is the channel for cancellation, deadlines, and dependency injection:

request := piko.NewTestRequest("GET", "/customers").
    WithQueryParam("sort", "desc").
    WithPathParam("id", "123").
    WithLocale("fr").
    Build(ctx)

Every With* method appears in testing API reference.

Inject dependencies through context

The cleanest way to swap a repository, clock, or service for a mock is via the context passed to Build:

mockRepo := &MockCustomerRepo{Customers: []Customer{{Name: "Acme"}}}
ctx := context.WithValue(context.Background(), "repo", mockRepo)

request := piko.NewTestRequest("GET", "/").Build(ctx)

Render reads the mock from the context:

func Render(r *piko.RequestData, props piko.NoProps) (Response, piko.Metadata, error) {
    repo := r.Context().Value("repo").(CustomerRepository)
    customers := repo.GetAll()
    // ...
}

A minimal mock records call flags so the test can assert the code path ran:

type MockCustomerRepo struct {
    Customers    []Customer
    GetAllCalled bool
    ShouldError  bool
}

func (m *MockCustomerRepo) GetAll() []Customer {
    m.GetAllCalled = true
    return m.Customers
}

Assert against the template AST

view.QueryAST(selector) runs a CSS selector against the rendered template. AST queries are faster than rendering HTML and do not require a configured RenderService. The query result chains assertion methods such as Count, HasText, HasAttribute, and HasClass. The full list is in testing API reference.

view.QueryAST("h1").HasText("Customers")
view.QueryAST(".customer-row").Count(10)
view.QueryAST("input[name='email']").HasAttribute("type", "email")
view.QueryAST(":not(.hidden)").MinCount(1)

CSS selector support includes combinators, attribute selectors, and pseudo-classes (:nth-child, :not, :first-of-type).

For targeted work on a subset, narrow with First(), At(i), or Filter:

active := view.QueryAST(".customer-row").Filter(func(n *ast_domain.TemplateNode) bool {
    status, _ := n.GetAttribute("data-status")
    return status == "active"
})
active.Count(5)

Assert metadata

piko.Metadata (title, description, status code, redirects, Open Graph) surfaces through dedicated Assert* methods on the view:

view.AssertTitle("Customers - MyApp")
view.AssertStatusCode(200)
view.AssertCanonicalURL("https://example.com/customers")
view.AssertHasOGTag("og:title", "Customers")

The testing API reference lists every assertion. For the underlying field definitions see metadata fields reference.

Render HTML when you need it

AST queries cover most cases. When a test genuinely needs the full rendered HTML (snapshot testing, regex checks on rendered attributes), call HTML():

html := view.HTMLString()
assert.Contains(t, html, "<h1>Customers</h1>")

HTML rendering requires a configured RenderService. See testing API reference.

Test server actions

piko.NewActionTester(t, entry) drives an action through an ActionHandlerEntry, the descriptor that pairs a constructor with its invoke function. Construct one in the test using the action struct from your own package and a small invoke shim, then call Invoke(ctx, arguments) with the parameter map. Assertions live on the returned *ActionResultView.

package actions_test

import (
    "context"
    "testing"

    "piko.sh/piko"
    "myapp/actions/customer"
)

func TestCustomerCreateAction_Success(t *testing.T) {
    mockRepo := &MockCustomerRepo{}
    ctx := context.WithValue(context.Background(), "repo", mockRepo)

    entry := piko.ActionHandlerEntry{
        Name:   "customer.Create",
        Method: "POST",
        Create: func() any { return &customer.CreateAction{} },
        Invoke: func(ctx context.Context, action any, arguments map[string]any) (any, error) {
            return action.(*customer.CreateAction).Run(ctx, arguments)
        },
    }

    tester := piko.NewActionTester(t, entry)
    result := tester.Invoke(ctx, map[string]any{
        "name":  "Acme Corp",
        "email": "[email protected]",
    })

    result.AssertSuccess()
    result.AssertHelper("redirect")
    assert.True(t, mockRepo.CreateCalled)
}

The Invoke shim adapts the action's typed input to the harness's map[string]any. In production the generated registry does this conversion for you. For unit tests it is usually simpler to call the action's exported method directly than to depend on generated code.

tester.Invoke accepts nil for actions with no parameters. The harness substitutes an empty map. To assert error cases:

result := tester.Invoke(ctx, map[string]any{"email": "bad"})

result.AssertError()
result.AssertErrorContains("invalid email")

To assert that the action registered no helpers, use result.AssertNoHelpers(). For raw access to the response data, call result.Data() or result.Err().

The action assertions are: AssertSuccess, AssertError, AssertErrorContains(substr), AssertHelper(name), AssertNoHelpers. See testing API reference for accessor methods.

Snapshot unexpected regressions

MatchSnapshot(name) writes a golden file on first run and compares on later runs:

view := tester.Render(request, piko.NoProps{})
view.MatchSnapshot("customers-page")

Snapshots live in __snapshots__/<test-directory>/<name>.golden.html. Regenerate after intentional changes:

PIKO_UPDATE_SNAPSHOTS=1 go test ./...

Benchmark a page

tester.Benchmark(req, props) runs the render in a benchmark loop:

func BenchmarkCustomersPage(b *testing.B) {
    mockRepo := &MockCustomerRepo{Customers: generateMockCustomers(100)}
    ctx := context.WithValue(context.Background(), "repo", mockRepo)

    request := piko.NewTestRequest("GET", "/customers").Build(ctx)
    tester := piko.NewComponentTester(b, customers.BuildAST)

    tester.Benchmark(request, piko.NoProps{})
}

Run with:

go test -bench=. -benchmem ./pages/...

Table-driven tests

The component and action testers compose naturally with t.Run sub-tests:

tests := []struct {
    name     string
    mockData []Customer
    want     int
}{
    {"empty", []Customer{}, 0},
    {"two customers", []Customer{{Name: "Acme"}, {Name: "Beta"}}, 2},
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        ctx := context.WithValue(context.Background(), "repo",
            &MockCustomerRepo{Customers: tt.mockData})
        request := piko.NewTestRequest("GET", "/").Build(ctx)

        view := piko.NewComponentTester(t, customers.BuildAST).Render(request, piko.NoProps{})
        view.QueryAST(".customer-row").Count(tt.want)
    })
}

Running tests

go test ./...                              # everything
go test -v ./pages/...                     # one package, verbose
go test -run TestCustomersPage ./pages/... # one test
go test -cover ./...                       # coverage
go test -bench=. -benchmem ./...           # benchmarks
go test -race ./...                        # race detection
PIKO_UPDATE_SNAPSHOTS=1 go test ./...      # refresh snapshots

See also