Frontend Architecture & Testability
The same problem exists in frontend as in backend: logic tangled with delivery makes code untestable and hard to change. The solution is the same too — separate concerns so that business logic can be verified without touching the UI.
> The goal is to push logic toward pure functions and away from the component tree.
---
The Core Problem
When logic lives inside components, you can only test it by rendering the UI — slow, fragile, and overkill for a simple calculation.
// ❌ BAD: Logic tangled inside the component
function CartPage() {
const [items, setItems] = useState<CartItem[]>([]);
const total = items.reduce((sum, item) => {
const discounted = item.price * (1 - item.discountRate);
return sum + discounted * item.quantity;
}, 0);
const tax = total * 0.11;
const grandTotal = total + tax;
return <div>Total: {grandTotal}</div>;
}
// To test grandTotal, you must render CartPage — just to verify math.
// ✅ GOOD: Logic extracted to a pure function
function calculateCartTotal(items: CartItem[]): CartTotal {
const subtotal = items.reduce((sum, item) => {
const discounted = item.price * (1 - item.discountRate);
return sum + discounted * item.quantity;
}, 0);
const tax = subtotal * 0.11;
return { subtotal, tax, grandTotal: subtotal + tax };
}
function CartPage() {
const [items, setItems] = useState<CartItem[]>([]);
const { grandTotal } = calculateCartTotal(items);
return <div>Total: {grandTotal}</div>;
}
// calculateCartTotal is a plain function — testable with zero UI setup.
---
Pattern 1 — Container / Presenter
Split every feature into two components: one that thinks, one that renders.
| Role | Responsibility | Testable with |
|---|---|---|
| Container | Fetches data, holds state, handles events | Unit tests on logic |
| Presenter | Renders props into UI, no logic | Snapshot / render tests |
// ========== PRESENTER: Pure render, no logic ==========
type UserCardProps = {
name: string;
email: string;
isOnline: boolean;
onLogout: () => void;
};
function UserCard({ name, email, isOnline, onLogout }: UserCardProps) {
return (
<div>
<h2>{name}</h2>
<p>{email}</p>
<span>{isOnline ? "Online" : "Offline"}</span>
<button onClick={onLogout}>Logout</button>
</div>
);
}
// Test: render with props, assert output. No API calls, no state.
// ========== CONTAINER: Logic and data, no JSX ==========
function UserCardContainer({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
const handleLogout = () => {
authService.logout();
};
if (!user) return <Spinner />;
return (
<UserCard
name={user.name}
email={user.email}
isOnline={user.status === "active"}
onLogout={handleLogout}
/>
);
}
// ========== Testing the Presenter ==========
import { render, screen } from "@testing-library/react";
test("shows Offline badge when isOnline is false", () => {
render(
<UserCard
name="Yudi"
email="yudi@example.com"
isOnline={false}
onLogout={() => {}}
/>
);
expect(screen.getByText("Offline")).toBeInTheDocument();
});
test("calls onLogout when button is clicked", async () => {
const onLogout = jest.fn();
const { user } = renderWithUser(
<UserCard name="Yudi" email="yudi@example.com" isOnline={true} onLogout={onLogout} />
);
await user.click(screen.getByRole("button", { name: /logout/i }));
expect(onLogout).toHaveBeenCalledTimes(1);
});
---
Pattern 2 — Custom Hooks as Logic Layer
Extract all stateful logic into custom hooks. The component becomes a thin rendering shell.
// ❌ BAD: Form logic lives inside the component
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!email.includes("@")) { setError("Invalid email"); return; }
setLoading(true);
try {
await authService.login(email, password);
} catch {
setError("Login failed");
} finally {
setLoading(false);
}
};
return (/* JSX */);
}
// Testing validation or submit flow requires a full render + event simulation.
// ✅ GOOD: Logic extracted to a custom hook
function useLoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const validate = () => {
if (!email.includes("@")) { setError("Invalid email"); return false; }
return true;
};
const submit = async () => {
if (!validate()) return;
setLoading(true);
try {
await authService.login(email, password);
} catch {
setError("Login failed");
} finally {
setLoading(false);
}
};
return { email, setEmail, password, setPassword, error, loading, submit };
}
// Component becomes trivial
function LoginForm() {
const form = useLoginForm();
return (
<form onSubmit={(e) => { e.preventDefault(); form.submit(); }}>
<input value={form.email} onChange={(e) => form.setEmail(e.target.value)} />
<input type="password" value={form.password} onChange={(e) => form.setPassword(e.target.value)} />
{form.error && <p>{form.error}</p>}
<button disabled={form.loading}>Login</button>
</form>
);
}
// ========== Testing the hook directly ==========
import { renderHook, act } from "@testing-library/react";
test("sets error when email has no @", async () => {
const { result } = renderHook(() => useLoginForm());
act(() => { result.current.setEmail("notanemail"); });
await act(() => result.current.submit());
expect(result.current.error).toBe("Invalid email");
});
// No component rendered — just the logic.
---
Pattern 3 — Pure Functions for Business Logic
Pure functions are the easiest unit to test: same input always produces same output, no side effects.
// ========== Extract calculations into pure functions ==========
// ❌ BAD: Calculation inside component
function PricingSummary({ plan, users }: Props) {
let price = plan === "pro" ? 29 : 9;
if (users > 10) price = price * users * 0.9;
else price = price * users;
const vat = price * 0.11;
// ...
}
// ✅ GOOD: Pure function, testable in isolation
function calculatePlanPrice(plan: "basic" | "pro", users: number): PriceSummary {
const basePerUser = plan === "pro" ? 29 : 9;
const subtotal = users > 10
? basePerUser * users * 0.9
: basePerUser * users;
return {
subtotal,
vat: subtotal * 0.11,
total: subtotal * 1.11,
};
}
function PricingSummary({ plan, users }: Props) {
const price = calculatePlanPrice(plan, users);
return <div>Total: {price.total}</div>;
}
// ========== Unit tests: fast, zero overhead ==========
import { calculatePlanPrice } from "./pricing";
test("basic plan, under 10 users: no bulk discount", () => {
const result = calculatePlanPrice("basic", 5);
expect(result.subtotal).toBe(45); // 9 * 5
});
test("pro plan, over 10 users: 10% bulk discount", () => {
const result = calculatePlanPrice("pro", 12);
expect(result.subtotal).toBeCloseTo(313.2); // 29 * 12 * 0.9
});
test("total includes 11% VAT", () => {
const result = calculatePlanPrice("basic", 1);
expect(result.total).toBeCloseTo(9 * 1.11);
});
---
Pattern 4 — Ports & Adapters (Hexagonal) Applied to Frontend
Treat external dependencies (APIs, localStorage, cookies) as adapters behind an interface. Core logic never imports them directly.
// ========== Define the Port (interface) ==========
interface AuthRepository {
login(email: string, password: string): Promise<User>;
logout(): Promise<void>;
getCurrentUser(): Promise<User | null>;
}
// ========== Real Adapter (production) ==========
class ApiAuthRepository implements AuthRepository {
async login(email: string, password: string) {
const res = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
return res.json();
}
async logout() { await fetch("/api/auth/logout", { method: "POST" }); }
async getCurrentUser() { return fetch("/api/auth/me").then((r) => r.json()); }
}
// ========== Fake Adapter (testing) ==========
class FakeAuthRepository implements AuthRepository {
private users: User[] = [{ id: "1", name: "Yudi", email: "yudi@example.com" }];
async login(email: string) {
const user = this.users.find((u) => u.email === email);
if (!user) throw new Error("Invalid credentials");
return user;
}
async logout() {}
async getCurrentUser() { return this.users[0]; }
}
// ========== Hook depends on the port, not the adapter ==========
function useAuth(repo: AuthRepository) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
const loggedIn = await repo.login(email, password);
setUser(loggedIn);
};
return { user, login };
}
// Production: inject real adapter
const auth = useAuth(new ApiAuthRepository());
// Tests: inject fake adapter — no network, no mocking fetch
const auth = useAuth(new FakeAuthRepository());
---
Testing Layers
A well-structured frontend has three layers of tests, each with a different cost/confidence trade-off.
Unit Tests → Pure functions and custom hooks in isolation
Fast (~ms), many, cover all edge cases
Integration Tests → Components rendered with real hooks + fake adapters
Medium speed, verify the wiring is correct
E2E Tests → Full browser flow against a real (or staging) server
Slow, few, cover critical user journeys only
// ========== Unit: pure function ==========
test("calculateDiscount returns 0 for empty cart", () => {
expect(calculateDiscount([])).toBe(0);
});
// ========== Integration: component + hook + fake repo ==========
test("LoginForm shows error message on bad credentials", async () => {
const fakeRepo = new FakeAuthRepository();
render(<LoginForm authRepo={fakeRepo} />);
await userEvent.type(screen.getByLabelText("Email"), "wrong@example.com");
await userEvent.type(screen.getByLabelText("Password"), "wrongpass");
await userEvent.click(screen.getByRole("button", { name: /login/i }));
expect(await screen.findByText("Invalid credentials")).toBeInTheDocument();
});
// ========== E2E: Playwright, real browser ==========
test("user can log in and see dashboard", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "yudi@example.com");
await page.fill('[name="password"]', "correctpassword");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
});
Recommended Split
| Layer | Volume | When it fails |
|---|---|---|
| Unit | ~70% | Logic is wrong |
| Integration | ~20% | Components are wired incorrectly |
| E2E | ~10% | Critical flows are broken end-to-end |
Common Mistakes
Testing Implementation Details
// ❌ BAD: Tests internal state, not behavior
test("sets loading to true on submit", () => {
const { result } = renderHook(() => useLoginForm());
act(() => result.current.submit());
expect(result.current.loading).toBe(true); // Brittle — internal detail
});
// ✅ GOOD: Tests what the user sees
test("shows spinner while submitting", async () => {
render(<LoginForm />);
await userEvent.click(screen.getByRole("button", { name: /login/i }));
expect(screen.getByRole("status")).toBeInTheDocument(); // Spinner visible
});
Fetching Data Inside Components You Want to Unit Test
// ❌ BAD: Component fetches directly — impossible to unit test
function ProductList() {
useEffect(() => {
fetch("/api/products").then(...);
}, []);
}
// ✅ GOOD: Accept data as props or inject the fetcher
function ProductList({ products }: { products: Product[] }) {
return products.map((p) => <ProductCard key={p.id} product={p} />);
}
// ProductList is now a pure presenter — testable with any array of products.
One Giant Component
// ❌ BAD: 300-line component with state, fetch, validation, and render mixed
function CheckoutPage() { /* ... everything ... */ }
// ✅ GOOD: Decomposed responsibilities
function useCheckout(repo: OrderRepository) { /* logic hook */ }
function validateOrder(order: Order): ValidationResult { /* pure function */ }
function CheckoutSummary({ order }: Props) { /* presenter */ }
function CheckoutPage({ repo }: Props) {
const checkout = useCheckout(repo);
const validation = validateOrder(checkout.order);
return <CheckoutSummary order={checkout.order} />;
}
---
Quick Reference
| Pattern | What it solves | Test with |
|---|---|---|
| Container / Presenter | Logic mixed with render | Render Presenter with props; unit test Container logic |
| Custom Hook | Stateful logic inside component | renderHook from Testing Library |
| Pure Function | Calculations inside component | Plain jest / vitest — no DOM needed |
| Ports & Adapters | Hard dependency on fetch/API | Swap real adapter for fake in tests |
Tools
# Testing Library — render components, simulate user events
npm install --save-dev @testing-library/react @testing-library/user-event
# Jest or Vitest — test runner
npm install --save-dev vitest # preferred for Vite/Next.js projects
npm install --save-dev jest # classic option
# Playwright — E2E browser testing
npm install --save-dev @playwright/test
npx playwright install
# Running tests
npx vitest # unit + integration (watch mode)
npx vitest run # single run
npx playwright test # E2E
npx vitest --coverage # with coverage report
---