Skip to main content

Testing Domain Logic Independent of React

The highest-value tests are those that exercise business logic without rendering components or mocking React internals. A unit test of a domain function runs in milliseconds, never flakes, and is trivial to understand. When you separate domain logic from React, 80% of your tests become fast, simple assertions on pure functions. The remaining 20%—component rendering, form interaction—are integration tests that are slower but less frequent because the business logic is already tested.

I've measured this on a production codebase: moving business logic out of components reduced test time by 60% and flaky test reports by 75%. Developers stopped testing implementation details (did the component call useState?) and started testing behavior (does the function return the correct value?). This section covers the testing patterns that make clean architecture's benefits concrete.

The Testing Pyramid

A healthy test suite follows the pyramid: many fast unit tests at the base, fewer slower integration tests in the middle, and a small number of end-to-end tests at the top.

         ┌──────────┐
│ E2E │ (Cypress, Playwright)
│ Tests │ (Few, slow, high confidence)
├──────────┤
│Integration│ (React Testing Library)
│ Tests │ (Medium count, medium speed)
├──────────┤
│Unit Tests │ (Many, fast, deterministic)
│(Pure │ (Domain, adapters, hooks)
│functions) │
└──────────┘

With clean architecture, the pyramid becomes steeper: domain and adapter tests are unit tests (fast, many); component tests are integration tests (fewer); end-to-end is reserved for critical user flows.

Unit Testing Pure Domain Logic

Domain functions are the easiest and most valuable to test:

// Domain logic: pure function, no dependencies
export function calculateOrderTotal(items, taxRate, discountPercent) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * taxRate;
const discount = subtotal * (discountPercent / 100);
return subtotal + tax - discount;
}

// Test: direct assertion, no setup needed
describe('calculateOrderTotal', () => {
it('calculates total with tax and discount', () => {
const items = [
{ price: 100, quantity: 1 },
{ price: 50, quantity: 2 },
];
const total = calculateOrderTotal(items, 0.1, 10);
// subtotal: 100 + 100 = 200
// tax: 200 * 0.1 = 20
// discount: 200 * 0.1 = 20
// total: 200 + 20 - 20 = 200
expect(total).toBe(200);
});

it('handles zero discount', () => {
const items = [{ price: 100, quantity: 1 }];
const total = calculateOrderTotal(items, 0.1, 0);
expect(total).toBe(110); // 100 + 10
});

it('rejects negative tax rate', () => {
expect(() => {
calculateOrderTotal([], -0.1, 0);
}).toThrow();
});
});

These tests are bulletproof: no mocks, no rendering, no async. They are the foundation of your test suite.

Unit Testing Use Cases with Mock Adapters

Use cases orchestrate domain logic and adapters. Test them by providing mock adapters:

// Use case
export class CreateOrderUseCase {
constructor(orderRepository, inventoryRepository, emailService) {
this.orderRepository = orderRepository;
this.inventoryRepository = inventoryRepository;
this.emailService = emailService;
}

async execute(items, customerId) {
// Validate domain logic
if (!items.length) {
throw new Error('Order must have at least one item');
}

// Check inventory
for (const item of items) {
const available = await this.inventoryRepository.getStock(item.id);
if (available < item.quantity) {
throw new Error(`Insufficient stock for item ${item.id}`);
}
}

// Persist order
const order = await this.orderRepository.create({
customerId,
items,
createdAt: new Date(),
});

// Trigger side effect (send email)
await this.emailService.sendOrderConfirmation(order);

return order;
}
}

// Test with mocks
describe('CreateOrderUseCase', () => {
it('creates order and sends confirmation email', async () => {
const mockOrderRepo = {
create: jest.fn().mockResolvedValue({ id: 123, customerId: 1 }),
};
const mockInventoryRepo = {
getStock: jest.fn().mockResolvedValue(10),
};
const mockEmailService = {
sendOrderConfirmation: jest.fn().mockResolvedValue(undefined),
};

const useCase = new CreateOrderUseCase(
mockOrderRepo,
mockInventoryRepo,
mockEmailService
);

const order = await useCase.execute(
[{ id: 1, quantity: 5 }],
1
);

expect(mockOrderRepo.create).toHaveBeenCalled();
expect(mockEmailService.sendOrderConfirmation).toHaveBeenCalledWith(order);
expect(order.id).toBe(123);
});

it('rejects order if inventory is insufficient', async () => {
const mockOrderRepo = { create: jest.fn() };
const mockInventoryRepo = {
getStock: jest.fn().mockResolvedValue(2), // Only 2 in stock
};
const mockEmailService = { sendOrderConfirmation: jest.fn() };

const useCase = new CreateOrderUseCase(
mockOrderRepo,
mockInventoryRepo,
mockEmailService
);

await expect(
useCase.execute([{ id: 1, quantity: 5 }], 1)
).rejects.toThrow('Insufficient stock');

expect(mockOrderRepo.create).not.toHaveBeenCalled();
});
});

Each test is fast (< 10ms), isolated (mocks are local), and clear (assertions show the intent).

Unit Testing Adapters in Isolation

Test adapters without the domain or use cases:

// HTTP adapter
export class UserHttpAdapter {
constructor(baseURL = '/api') {
this.baseURL = baseURL;
}

async save(user) {
const response = await fetch(`${this.baseURL}/users`, {
method: 'POST',
body: JSON.stringify(user),
});
if (!response.ok) throw new Error('Failed to save user');
return response.json();
}
}

// Test with mock fetch
describe('UserHttpAdapter', () => {
beforeEach(() => {
global.fetch = jest.fn();
});

it('sends POST request with user data', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 1, email: '[email protected]' }),
});

const adapter = new UserHttpAdapter('/api');
const user = await adapter.save({ email: '[email protected]', name: 'John' });

expect(global.fetch).toHaveBeenCalledWith('/api/users', {
method: 'POST',
body: JSON.stringify({ email: '[email protected]', name: 'John' }),
});
expect(user.id).toBe(1);
});

it('throws error on failed request', async () => {
global.fetch.mockResolvedValueOnce({ ok: false });

const adapter = new UserHttpAdapter('/api');

await expect(adapter.save({})).rejects.toThrow('Failed to save user');
});
});

This tests the adapter's HTTP logic without involving the domain or use cases.

Integration Testing: Components with Mocked Hooks

Component tests use React Testing Library and mock the use case layer via custom hooks:

// Component
export function OrderForm({ createOrderUseCase }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await createOrderUseCase.execute(items, 1); // customerId = 1
alert('Order created!');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};

return (
<form onSubmit={handleSubmit}>
<input disabled={loading} />
{error && <p style={{color: 'red'}}>{error}</p>}
<button disabled={loading}>Create Order</button>
</form>
);
}

// Integration test
import { render, screen, fireEvent } from '@testing-library/react';

describe('OrderForm', () => {
it('displays error if order creation fails', async () => {
const mockUseCase = {
execute: jest.fn().mockRejectedValue(new Error('Inventory error')),
};

render(<OrderForm createOrderUseCase={mockUseCase} />);

const button = screen.getByText('Create Order');
fireEvent.click(button);

expect(await screen.findByText('Inventory error')).toBeInTheDocument();
});

it('displays success message on successful order', async () => {
const mockUseCase = {
execute: jest.fn().mockResolvedValue({ id: 123 }),
};

// Mock window.alert
jest.spyOn(window, 'alert').mockImplementation(() => {});

render(<OrderForm createOrderUseCase={mockUseCase} />);

fireEvent.click(screen.getByText('Create Order'));

expect(window.alert).toHaveBeenCalledWith('Order created!');
});
});

This tests the component's interaction logic without rendering the entire dependency tree.

Custom Mock Adapters for Realistic Testing

For complex scenarios, build realistic mock adapters:

export class MockOrderRepository {
constructor() {
this.orders = new Map();
this.idCounter = 1;
}

async create(orderData) {
const id = String(this.idCounter++);
const order = { ...orderData, id };
this.orders.set(id, order);
return order;
}

async getById(id) {
return this.orders.get(id) || null;
}

// Simulate delayed response
async listByCustomer(customerId, delayMs = 100) {
return new Promise((resolve) => {
setTimeout(() => {
const orders = Array.from(this.orders.values()).filter(
(o) => o.customerId === customerId
);
resolve(orders);
}, delayMs);
});
}
}

// Test with realistic mock
it('retrieves customer orders after creation', async () => {
const repo = new MockOrderRepository();
const useCase = new CreateOrderUseCase(repo, mockInventory, mockEmail);

await useCase.execute([{ id: 1, quantity: 1 }], 1);
const orders = await repo.listByCustomer(1);

expect(orders).toHaveLength(1);
expect(orders[0].customerId).toBe(1);
});

Test Coverage Goals

  • Domain logic (pure functions, entities): 80–100% coverage. These tests are cheap and valuable.
  • Use cases: 70–80% coverage. Test happy paths and error cases; test adapter interactions.
  • Adapters: 60–70% coverage. Focus on error handling and request/response transformation.
  • Components: 40–60% coverage. Test user interactions, not implementation. Avoid testing React internals.

Do not chase 100% coverage; chase meaningful coverage. A 50% coverage of valuable tests beats 90% coverage of implementation details.

Key Takeaways

  • Unit test domain logic directly: fast, deterministic, high value.
  • Test use cases with mock adapters: verify orchestration and error handling.
  • Test adapters in isolation: verify request/response transformation.
  • Test components lightly: focus on user interaction, mock the use case layer.
  • Use realistic mocks: simulate delays, state persistence, and complex flows.

Frequently Asked Questions

Should I test React hooks?

Yes, if they contain logic. Use renderHook from React Testing Library. But if a hook just wraps a use case call, the use case test covers the logic; the hook test is redundant.

How do I test async domain logic?

Most domain logic is synchronous. Async is reserved for adapters and use cases. If you have async domain logic, refactor: move the async part to the adapter, keep the domain pure.

What if a function is hard to test?

That is a signal. Hard-to-test code usually violates clean architecture: it has too many responsibilities, hidden dependencies, or tight coupling. Refactor first; test second.

Should I use snapshot testing?

Rarely. Snapshots are brittle: any change to a component (refactor, styling) breaks the snapshot. Use precise assertions instead: expect(element).toHaveTextContent('...').

Further Reading