Docs
Testing
Test Proa components with unit, snapshot, accessibility, and benchmark suites.
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.
#[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:
#[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:
#[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("<script>"));
assert!(!html.contains("<script>alert"));
}
Testing optional fields
Cover both the present and the absent case for every Option field:
#[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
[dev-dependencies]
insta = "1"
#[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:
cargo install cargo-insta
cargo insta review # interactive review of changed snapshots
Full page snapshots
Snapshot an entire page render to catch layout regressions:
#[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
npm install -D @playwright/test @axe-core/playwright
npx playwright install
Accessibility checks
// 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
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
# 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
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "render_bench"
harness = false
// 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:
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:
# 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/
├── 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
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
- CI / GitHub Actions
- Wire the full check, lint, and test pipeline into GitHub Actions.
- Accessibility
- Fix the violations axe-core reports.
- Writing performant pages
- Read the benchmark numbers and remove the allocations behind them.
- HTML rendering
- Review the render traits these tests call.