This guide covers testing, linting, and type checking across the React Router Gospel Stack monorepo.
⚠️ Important: All commands should be run from the monorepo root directory unless specified otherwise.
The stack includes comprehensive testing and quality assurance tools:
- Vitest - Fast unit testing framework
- Playwright - End-to-end testing
- Testing Library - React component testing utilities
- ESLint - Code linting
- TypeScript - Static type checking
- Prettier - Code formatting
# Run all unit tests in the monorepo
pnpm run test
# Run tests in watch mode
pnpm run test:dev
# Run tests for a specific package
pnpm run test --filter=@react-router-gospel-stack/web-utils
# Run tests with coverage
pnpm run test -- --coverageimport { afterEach, beforeEach, describe, expect, it } from "vitest";
describe("MyFunction", () => {
beforeEach(() => {
// Setup before each test
});
afterEach(() => {
// Cleanup after each test
});
it("should return the correct value", () => {
const result = myFunction(42);
expect(result).toBe(84);
});
it("should handle edge cases", () => {
expect(myFunction(0)).toBe(0);
expect(() => myFunction(-1)).toThrow();
});
});import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MyComponent } from "./my-component";
describe("MyComponent", () => {
it("renders correctly", () => {
render(<MyComponent title="Hello" />);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
it("handles user interaction", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<MyComponent onClick={handleClick} />);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});
it("updates after async operation", async () => {
render(<MyComponent />);
await waitFor(() => {
expect(screen.getByText("Loaded")).toBeInTheDocument();
});
});
});import { vi } from "vitest";
// Mock a function
const mockFn = vi.fn();
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue("async result");
// Mock a module
vi.mock("./module", () => ({
myFunction: vi.fn(() => "mocked"),
}));
// Spy on a function
const spy = vi.spyOn(object, "method");Tests are configured per package in their vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom", // or "node" for server-side tests
setupFiles: ["./tests/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
},
},
});web-utils- Unit tests for TypeScript utilitieswebapp- React component tests with Testing Library
# Run E2E tests (webapp must be running)
pnpm run test:e2e --filter=@react-router-gospel-stack/webapp
# Run E2E tests in dev mode (starts dev server)
pnpm run test:e2e:dev --filter=@react-router-gospel-stack/webapp
# Run E2E tests in headed mode (see browser)
pnpm run test:e2e:dev --filter=@react-router-gospel-stack/webapp -- --headed
# Run specific test file
pnpm run test:e2e:dev --filter=webapp -- tests/e2e/app.spec.tsimport { expect, test } from "@playwright/test";
test.describe("Homepage", () => {
test("should load successfully", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/React Router Gospel Stack/);
});
test("should navigate to about page", async ({ page }) => {
await page.goto("/");
await page.click('a[href="/about"]');
await expect(page).toHaveURL(/.*about/);
});
});test("should submit form", async ({ page }) => {
await page.goto("/signup");
// Fill in the form
await page.fill('input[name="email"]', "test@example.com");
await page.fill('input[name="password"]', "password123");
// Submit
await page.click('button[type="submit"]');
// Wait for navigation or success message
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator(".success-message")).toBeVisible();
});test("should load data from API", async ({ page }) => {
// Intercept API calls
await page.route("**/api/users", (route) => {
route.fulfill({
status: 200,
body: JSON.stringify([{ id: 1, name: "John Doe" }]),
});
});
await page.goto("/users");
await expect(page.locator(".user-name")).toHaveText("John Doe");
});import { test as base } from "@playwright/test";
// Extend base test with authentication
const test = base.extend({
page: async ({ page }, use) => {
// Login before each test
await page.goto("/login");
await page.fill('input[name="email"]', "user@example.com");
await page.fill('input[name="password"]', "password");
await page.click('button[type="submit"]');
await page.waitForURL("/dashboard");
await use(page);
},
});
test("authenticated user can access dashboard", async ({ page }) => {
// Page is already authenticated
await expect(page.locator(".dashboard")).toBeVisible();
});E2E tests are configured in apps/webapp/playwright.config.ts:
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
webServer: {
command: "pnpm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
},
});The webapp includes test utilities in tests/:
db-utils.ts- Database helpers for testingplaywright-utils.ts- Common Playwright utilitiesmocks/- Mock data and handlers
# Lint all files in the monorepo
pnpm run lint
# Lint specific package
pnpm run lint --filter=@react-router-gospel-stack/webapp
# Auto-fix issues
pnpm run lint -- --fixESLint configurations are in the config/eslint package:
// Base configuration
import baseConfig from "@react-router-gospel-stack/eslint-config/base";
export default [
...baseConfig,
{
rules: {
// Your custom rules
},
},
];The stack includes custom ESLint rules in config/eslint/rules/:
throw-redirect.js- Ensures redirects are properly thrown in React Router
When you need to disable a rule:
// Disable for a single line
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = await fetchData();
// Disable for a block
/* eslint-disable @typescript-eslint/no-explicit-any */
const data: any = await fetchData();
const result: any = processData(data);
/* eslint-enable @typescript-eslint/no-explicit-any */
// Disable for entire file (use sparingly!)
/* eslint-disable @typescript-eslint/no-explicit-any */# Type check entire monorepo
pnpm run typecheck
# Type check specific package
pnpm run typecheck --filter=@react-router-gospel-stack/webapp
# Watch mode (checks on file changes)
pnpm run typecheck --filter=webapp -- --watchTypeScript configs are in the config/tsconfig package:
{
"extends": "@react-router-gospel-stack/tsconfig/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
}
},
"include": ["app/**/*", "server/**/*"],
"exclude": ["node_modules"]
}If Prisma types are missing:
pnpm run db:generateIf TypeScript can't find a module:
- Ensure the package is built:
pnpm run build --filter=<package> - Check
tsconfig.jsonpaths configuration - Restart your editor's TypeScript server
The stack uses TypeScript strict mode. Common issues:
// ❌ Error: Object is possibly 'null'
const name = user.name.toUpperCase();
// ✅ Use optional chaining
const name = user?.name?.toUpperCase();
// ✅ Or type guard
if (user && user.name) {
const name = user.name.toUpperCase();
}
// ❌ Error: Parameter 'x' implicitly has an 'any' type
function double(x) {
return x * 2;
}
// ✅ Add explicit type
function double(x: number): number {
return x * 2;
}# Format all files
pnpm run format
# Check formatting without changing files
pnpm run format:checkPrettier is configured in .prettierrc:
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}Install Prettier extensions for your editor:
- VS Code: Prettier - Code formatter
- WebStorm: Built-in, enable in Settings → Languages & Frameworks → Prettier
Enable "Format on Save" for automatic formatting.
Tests run automatically on pull requests and pushes via GitHub Actions.
Workflow includes:
- Install dependencies
- Build packages
- Run linting
- Run type checking
- Run unit tests
- Run E2E tests
- Deploy (if on main/dev branch)
Test your changes locally before pushing:
# Run the full CI pipeline locally
pnpm run lint
pnpm run typecheck
pnpm run test
pnpm run build
pnpm run test:e2e --filter=webapp-
Test behavior, not implementation
// ❌ Testing implementation details expect(component.state.isOpen).toBe(true); // ✅ Testing behavior expect(screen.getByRole("dialog")).toBeVisible();
-
Use descriptive test names
// ❌ Vague it("works", () => { ... }); // ✅ Descriptive it("should display error message when email is invalid", () => { ... });
-
Keep tests focused
- One assertion per test (when possible)
- Test one scenario per test case
-
Use arrange-act-assert pattern
it("should calculate total correctly", () => { // Arrange const items = [{ price: 10 }, { price: 20 }]; // Act const total = calculateTotal(items); // Assert expect(total).toBe(30); });
-
Test critical user journeys
- Authentication flow
- Core features
- Payment processes
- Form submissions
-
Use data-testid for stability
// ✅ Stable selector <button data-testid="submit-button">Submit</button> await page.click('[data-testid="submit-button"]'); // ❌ Fragile selector await page.click('.btn-primary.mt-4 > span');
-
Clean up test data
test.afterEach(async () => { await cleanupDatabase(); });
-
Use page objects for complex flows
class LoginPage { constructor(private page: Page) {} async login(email: string, password: string) { await this.page.goto("/login"); await this.page.fill('input[name="email"]', email); await this.page.fill('input[name="password"]', password); await this.page.click('button[type="submit"]'); } } test("user can login", async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.login("user@example.com", "password"); await expect(page).toHaveURL("/dashboard"); });
-
Fix linting errors before committing
pnpm run lint -- --fix
-
Don't disable rules without good reason
- Document why when you must disable a rule
-
Use strict TypeScript
- Don't use
anyunless absolutely necessary - Prefer
unknownoveranywhen you must
- Don't use
# Run tests with debug output
pnpm run test -- --reporter=verbose
# Run single test file
pnpm run test -- path/to/test.test.ts
# Debug in VS Code
# Add breakpoint and press F5 with Jest/Vitest debug config# Run in headed mode (see browser)
pnpm run test:e2e:dev --filter=webapp -- --headed
# Run with debug mode
pnpm run test:e2e:dev --filter=webapp -- --debug
# Use Playwright Inspector
PWDEBUG=1 pnpm run test:e2e --filter=webappAfter E2E tests run:
cd apps/webapp
pnpm playwright show-report# Unit test coverage
pnpm run test -- --coverage
# View HTML report
open coverage/index.html # macOS
xdg-open coverage/index.html # Linux
start coverage/index.html # WindowsAim for:
- 80%+ overall coverage for production code
- 100% coverage for critical business logic
- Lower coverage acceptable for UI/presentation code
Consider setting up pre-commit hooks with Husky:
pnpm add -D husky lint-staged
# Configure in package.json
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
}Run critical checks before pushing:
pnpm run typecheck && pnpm run lint && pnpm run test- Check Node version - Ensure CI uses the same version
- Check environment variables - CI may have different env vars
- Check timing issues - Add longer timeouts for CI:
test("slow operation", { timeout: 10000 }, async ({ page }) => { // ... });
-
Add explicit waits
await page.waitForSelector('[data-testid="loaded"]');
-
Use waitFor utilities
await waitFor(() => { expect(screen.getByText("Success")).toBeInTheDocument(); });
-
Increase timeouts for slow operations
# Regenerate types (if using Prisma)
pnpm run db:generate
# Rebuild packages
pnpm run build
# Restart TypeScript server in your editor- Set up Storybook for component documentation
- Add visual regression testing with Chromatic
- Integrate with Codecov for coverage tracking
- Explore the Architecture to understand the testing structure
- Review Development Guide for workflow integration