Testing Overview

Understand Nebutra's testing strategy, commands, coverage requirements, and CI integration.

Nebutra uses a three-layer testing pyramid: fast unit tests for logic correctness, integration tests for API contracts, and Playwright E2E tests for critical user flows.

Testing Pyramid


       /E2E\         5%  — Playwright — Critical flows (sign-up, create project, upgrade)
      /------\
     /  Integ \     15%  — Vitest — API endpoints, DB operations, queue handlers
    /----------\
   /    Unit    \   80%  — Vitest — Functions, utilities, React components
  /--------------\

Keep the pyramid shape intentional. Unit tests are fast and cheap — write many. E2E tests are slow and fragile — write few, focused on the most critical user journeys.

Test Commands

CommandWhat It Runs
pnpm testAll unit + integration tests (Vitest)
pnpm test:watchVitest in watch mode for development
pnpm test:coverageVitest with V8 coverage report
pnpm e2ePlaywright E2E suite (requires running app)
pnpm e2e:uiPlaywright UI mode for visual debugging
pnpm test:archArchitecture tests (import boundary validation)

Coverage Requirements

MetricMinimum
Line coverage80%
Function coverage80%
Branch coverage70%
Statement coverage80%

Coverage is measured per-package. A package that falls below threshold will fail the CI check.

To view the coverage report locally:

pnpm test:coverage
# Opens coverage/index.html in your browser

Writing Unit Tests (Vitest)

Test files are co-located with source files, using the .test.ts or .spec.ts suffix:

packages/commerce/metering/src/
  metering.ts
  metering.test.ts      ← co-located unit test

Example: Testing Pure Logic

import { describe, it, expect } from "vitest";
import { calculateOverageFee } from "@nebutra/metering";

describe("calculateOverageFee", () => {
  it("returns zero when usage is within limit", () => {
    expect(
      calculateOverageFee({ used: 5000, limit: 10000, pricePerUnit: 0.001 })
    ).toBe(0);
  });

  it("calculates fee for usage exceeding limit", () => {
    expect(
      calculateOverageFee({ used: 12000, limit: 10000, pricePerUnit: 0.001 })
    ).toBe(2);
  });

  it("handles exact limit (no overage)", () => {
    expect(
      calculateOverageFee({ used: 10000, limit: 10000, pricePerUnit: 0.001 })
    ).toBe(0);
  });
});

Example: Testing React Components

import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { CreateProjectButton } from "./create-project-button";

describe("CreateProjectButton", () => {
  it("calls onSubmit with the project name", async () => {
    const onSubmit = vi.fn();
    render(<CreateProjectButton onSubmit={onSubmit} />);

    await userEvent.click(screen.getByRole("button", { name: /create project/i }));
    await userEvent.type(screen.getByRole("textbox", { name: /project name/i }), "My Project");
    await userEvent.click(screen.getByRole("button", { name: /submit/i }));

    expect(onSubmit).toHaveBeenCalledWith({ name: "My Project" });
  });
});

Writing E2E Tests (Playwright)

E2E test files live in the e2e/ directory at the root of each app:

apps/web/
  e2e/
    auth.spec.ts          ← sign-up, sign-in, sign-out
    projects.spec.ts      ← create, rename, delete project
    billing.spec.ts       ← upgrade, downgrade, cancel

Example: E2E User Flow

import { test, expect } from "@playwright/test";

test("user can create a project", async ({ page }) => {
  await page.goto("/dashboard");
  await page.click('[data-testid="create-project-button"]');
  await page.fill('[name="projectName"]', "My Test Project");
  await page.click('[type="submit"]');
  await expect(
    page.locator('[data-testid="project-card"]')
  ).toContainText("My Test Project");
});

data-testid Convention

All E2E-targeted elements must use data-testid attributes:

// ✅ Testable
<button type="button" data-testid="create-project-button">
  Create Project
</button>

// ❌ Brittle — tied to copy that may change
await page.click('text=Create Project');

Mocking Strategy

DependencyMock approach
Stripevi.mock("stripe") with fixture responses
Clerk / Authvi.mock("@clerk/nextjs") with mock session
PostHogvi.mock("@nebutra/analytics") — no-op stubs
Emailvi.mock("@nebutra/email") — capture sent emails
Database (unit)In-memory SQLite via Prisma test client
Database (integration)Real test DB — never mock the DB layer
External HTTP APIsmsw (Mock Service Worker) for integration tests

Never mock the database in integration tests. Use a dedicated test database seeded with fixtures. This ensures your SQL queries, schema constraints, and indexes are tested against real data.

Architecture Tests

Architecture tests validate that package import boundaries are respected and that CSS token conventions are followed:

pnpm test:arch

These tests use vitest + fast-check to assert rules such as:

  • apps/* must not import from each other
  • packages/ui must not import from apps/*
  • No hardcoded hex values in component files (must use CSS variables)

Architecture tests run in under 5 seconds and catch cross-boundary imports before they become entrenched.

CI Integration

All test types run on every pull request via GitHub Actions:

# .github/workflows/ci.yml (excerpt)
jobs:
  test:
    steps:
      - run: pnpm test:coverage    # unit + integration with coverage
      - run: pnpm test:arch        # architecture boundary checks
      - run: pnpm e2e              # Playwright E2E against preview deployment

Coverage results are posted as a comment on each PR. A PR cannot be merged if:

  • Unit/integration coverage drops below the threshold
  • Any test fails
  • Architecture boundary violations are detected

Test-Driven Development

The project follows a TDD approach for new features:

Write the test before writing implementation code. Run it and confirm it fails.

Write just enough code to make the test pass. Do not over-engineer.

Clean up the implementation without breaking the test.

Run pnpm test:coverage and confirm the new code is covered at the required threshold.


How is this guide?

Edit on GitHub

Last updated on

On this page