Docs

Testing

Test Proa components with unit, snapshot, accessibility, and benchmark suites.

Open Markdown

A Proa component is a plain Rust struct that implements WebRenderSync or WebRender. This page covers unit tests, snapshot tests, browser checks with axe-core, benchmarks, and where the files live.

Writing unit tests

Render the component into a buffer, turn the buffer into a string, and assert on the HTML.

src/components/badge/badge.rs
#[cfg(test)]
mod tests {
    use super::*;
    use proa_core::text::text;
    use proa_core::WebContext;

    #[test]
    fn badge_renders_label() {
        let mut cx = WebContext::new();

        Badge {
            variant: BadgeVariant::Default,
            class: None,
            children: Some(text("New")),
        }
        .render(&mut cx)
        .unwrap();

        let html = cx.into_output().into_string();
        assert!(html.contains("data-badge"));
        assert!(html.contains("data-variant=\"default\""));
        assert!(html.contains("New"));
    }
}

RenderBuffer matches the standard production buffered writer pattern and still keeps unit tests small.

Testing variants

Iterate over all variants to ensure each one renders:

src/components/badge/badge.rs
#[test]
fn badge_renders_all_variants() {
    for variant in [
        BadgeVariant::Default,
        BadgeVariant::Secondary,
        BadgeVariant::Destructive,
        BadgeVariant::Outline,
    ] {
        let mut cx = WebContext::new();

        Badge {
            variant,
            class: None,
            children: Some(text("Test")),
        }
        .render(&mut cx)
        .unwrap();

        let html = cx.into_output().into_string();
        assert!(html.contains("data-badge"));
        assert!(
            html.contains(&format!("data-variant=\"{}\"", variant.as_str())),
            "variant {:?} did not render data-variant attribute",
            variant,
        );
    }
}

Testing dynamic content

Assert that Proa escapes dynamic values:

src/components/badge/badge.rs
#[test]
fn badge_escapes_xss_in_label() {
    let mut cx = WebContext::new();

    Badge {
        variant: BadgeVariant::Default,
        class: None,
        children: Some(text("<script>alert('xss')</script>")),
    }
    .render(&mut cx)
    .unwrap();

    let html = cx.into_output().into_string();
    // Must be escaped, not raw script tag
    assert!(html.contains("&lt;script&gt;"));
    assert!(!html.contains("<script>alert"));
}

Testing optional fields

Cover both the present and the absent case for every Option field:

src/components/card/card.rs
#[test]
fn card_without_optional_class() {
    let mut cx = WebContext::new();

    Card {
        class: None,
        children: Some(text("Test")),
    }
    .render(&mut cx)
    .unwrap();

    let html = cx.into_output().into_string();
    assert!(html.contains("data-card"));
}

#[test]
fn card_with_custom_class() {
    let mut cx = WebContext::new();

    Card {
        class: Some("my-custom-class"),
        children: Some(text("Test")),
    }
    .render(&mut cx)
    .unwrap();

    let html = cx.into_output().into_string();
    assert!(html.contains("my-custom-class"));
}

One assert! per attribute stops scaling once a component renders a full page. Snapshots take over there.

Writing snapshot tests

For larger components or full pages, snapshot the HTML output to catch unexpected changes.

With insta

insta is the standard Rust snapshot testing library:

Cargo.toml
# Cargo.toml
[dev-dependencies]
insta = "1"
src/components/listing_card/listing_card.rs
#[cfg(test)]
mod tests {
    use super::*;
    use proa_core::WebContext;

    #[test]
    fn listing_card_snapshot() {
        let mut cx = WebContext::new();

        ListingCard {
            title: "Cozy Mountain Cabin",
            location: "Lake Tahoe, CA",
            price: 185,
            image_url: "/images/cabin.jpg",
        }
        .render(&mut cx)
        .unwrap();

        let html = cx.into_output().into_string();
        insta::assert_snapshot!(html);
    }
}

Run cargo test. On the first run, insta writes a .snap file into a snapshots/ directory next to the test file. On later runs, it compares against that snapshot and fails when the output changes.

Review and update snapshots with:

Terminal
cargo install cargo-insta
cargo insta review   # interactive review of changed snapshots

Full page snapshots

Snapshot an entire page render to catch layout regressions:

tests/pages.rs
#[test]
fn home_page_snapshot() {
    let mut cx = WebContext::new();

    HomePage {
        user: &test_user(),
        listings: &test_listings(),
        filters: &default_filters(),
    }
    .render(&mut cx)
    .unwrap();

    let html = cx.into_output().into_string();
    insta::assert_snapshot!(html);
}

Snapshots pin the bytes you emit. They say nothing about how a browser treats them.

Testing in a browser

For end-to-end accessibility and rendering verification, use Playwright with axe-core.

Setup

Terminal
npm install -D @playwright/test @axe-core/playwright
npx playwright install

Accessibility checks

tests/a11y.spec.ts
// tests/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("home page has no accessibility violations", async ({ page }) => {
  await page.goto("http://localhost:3000/");

  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa"])  // WCAG 2 Level A + AA
    .analyze();

  expect(results.violations).toEqual([]);
});

test("listing page has no accessibility violations", async ({ page }) => {
  await page.goto("http://localhost:3000/rooms/12345");

  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa"])
    .analyze();

  expect(results.violations).toEqual([]);
});

Visual regression

tests/visual.spec.ts
// tests/visual.spec.ts
import { test, expect } from "@playwright/test";

test("listing card matches screenshot", async ({ page }) => {
  await page.goto("http://localhost:3000/rooms/12345");

  const card = page.locator("[data-listing-card]").first();
  await expect(card).toHaveScreenshot("listing-card.png");
});

Running the browser suite

Terminal
# Start Proa server through the linked pipeline
proa run &

# Run Playwright tests
npx playwright test

Correct output is one half of the contract. Render cost is the other half.

Catching performance regressions

Use cargo bench with criterion to catch render performance regressions.

Cargo.toml
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "render_bench"
harness = false
benches/render_bench.rs
// benches/render_bench.rs
use criterion::{criterion_group, criterion_main, Criterion};
use proa_core::RenderBuffer;

fn bench_listing_card(c: &mut Criterion) {
    let mut buf = RenderBuffer::new();
    c.bench_function("listing_card", |b| {
        b.iter(|| {
            buf.render_into(ListingCard {
                title: "Cozy Mountain Cabin",
                location: "Lake Tahoe, CA",
                price: 185,
                image_url: "/images/cabin.jpg",
            });
        });
    });
}

fn bench_full_page(c: &mut Criterion) {
    let listings = generate_test_listings(20);
    let mut buf = RenderBuffer::new();

    c.bench_function("home_page_20_listings", |b| {
        b.iter(|| {
            buf.render_into(HomePage {
                listings: &listings,
                ..Default::default()
            });
        });
    });
}

criterion_group!(benches, bench_listing_card, bench_full_page);
criterion_main!(benches);

Run benchmarks:

Terminal
cargo bench                    # run all benchmarks
cargo bench -- listing_card    # run one benchmark

Criterion generates HTML reports in target/criterion/ with charts showing performance over time. Use these in CI to catch regressions:

Terminal
# In CI: record benchmark output to compare against a stored baseline
cargo bench -- --output-format bencher | tee bench_results.txt

Four suites need four homes in the repository.

Organizing tests and CI

Use this structure:

my_app/
my_app/
├── src/
│   └── components/
│       └── listing_card/
│           ├── mod.rs
│           ├── listing_card.rs    # component + #[cfg(test)] mod tests
│           └── recipe.rs
├── benches/
│   └── render_bench.rs            # criterion benchmarks
├── tests/
│   └── pages.rs                   # full page render tests
└── playwright/
    └── tests/
        ├── a11y.spec.ts           # axe-core accessibility
        └── visual.spec.ts         # screenshot comparison

Unit tests live next to the component, in the same file under #[cfg(test)]. Integration tests live in tests/. Playwright tests live in their own directory.

On a crate that contains RSJS islands, run integration-test targets through the linked pipeline, proa test -p my_app --test pages: a plain cargo test integration binary links the island code without its compiled JavaScript modules and fails at link time by design. Lib tests under #[cfg(test)] are unaffected.

CI pipeline

.github/workflows/test.yml
# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Tests
        run: proa test

      # proa test runs the application suite through the linked pipeline.
      # Name one target in a multi-target workspace: proa test -p my_app --test pages

      - name: Benchmarks (check for regressions)
        run: cargo bench --no-run  # compile only in CI; run nightly

      - name: Start server
        run: proa run &

      - name: Playwright a11y + visual
        run: npx playwright test

Next steps

Search

Type at least 2 characters