Integration Testing Guide
What Are Integration Tests?
Integration tests verify that multiple parts of your application work together correctly. They test the integration between components, services, and external systems while maintaining reasonable speed and reliability.
The Sweet Spot
Integration tests provide the best balance of:
- Confidence: Testing real user scenarios
- Speed: Faster than E2E tests
- Maintenance: More stable than E2E, more valuable than unit tests
- Coverage: Can test most of your application's functionality
What to Test with Integration Tests
Component Integration
Test how components work together with real (or minimally mocked) dependencies:
// ❌ Unit test with excessive mocking
test('NotesList displays notes', () => {
const mockUseSupabase = jest.fn();
const mockUseQuery = jest.fn(() => ({ data: mockNotes }));
// Too isolated, doesn't test real behavior
});
// ✅ Integration test with real hooks and providers
test('NotesList fetches and displays user notes', async () => {
render(
<QueryClientProvider client={queryClient}>
<SupabaseProvider>
<NotesList userId="123" />
</SupabaseProvider>
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('My First Note')).toBeInTheDocument();
expect(screen.getByText('My Second Note')).toBeInTheDocument();
});
});
API Integration
Test server actions and API routes with real business logic:
// app/home/[account]/notes/_lib/server/server-actions.test.ts
import { createNoteAction } from './server-actions';
import { createMockUser, createMockAccount } from '@/test-utils/factories';
describe('createNoteAction', () => {
test('creates note with proper authorization', async () => {
const user = await createMockUser();
const account = await createMockAccount({ ownerId: user.id });
const result = await createNoteAction(
{
title: 'Integration Test Note',
content: 'Testing with real database',
accountId: account.id
},
user // enhanceAction provides this
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
title: 'Integration Test Note',
account_id: account.id,
created_by: user.id
});
// Verify it's actually in the database
const { data } = await getSupabaseServerClient()
.from('notes')
.select('*')
.eq('id', result.data.id)
.single();
expect(data).toBeTruthy();
});
test('prevents unauthorized access', async () => {
const user = await createMockUser();
const otherAccount = await createMockAccount(); // Different owner
await expect(
createNoteAction(
{
title: 'Unauthorized',
content: 'Should fail',
accountId: otherAccount.id
},
user
)
).rejects.toThrow('Insufficient permissions');
});
});
Database Integration
Test with real database operations and RLS policies:
// packages/features/team-accounts/src/server/api.test.ts
describe('Team Accounts API', () => {
let testDb: SupabaseClient;
beforeEach(async () => {
testDb = createTestDatabaseClient();
});
test('loadTeamAccountMembers respects RLS policies', async () => {
// Setup: Create team with members
const owner = await createMockUser();
const member = await createMockUser();
const outsider = await createMockUser();
const team = await createMockTeamAccount({
ownerId: owner.id,
members: [
{ userId: owner.id, role: 'owner' },
{ userId: member.id, role: 'member' }
]
});
// Test: Owner can see all members
const ownerClient = createSupabaseClient({ user: owner });
const ownerView = await loadTeamAccountMembers(ownerClient, team.slug);
expect(ownerView).toHaveLength(2);
// Test: Member can see members
const memberClient = createSupabaseClient({ user: member });
const memberView = await loadTeamAccountMembers(memberClient, team.slug);
expect(memberView).toHaveLength(2);
// Test: Outsider cannot see members
const outsiderClient = createSupabaseClient({ user: outsider });
await expect(
loadTeamAccountMembers(outsiderClient, team.slug)
).rejects.toThrow();
});
});
Testing Patterns
Testing Server Components
// app/home/[account]/team-dashboard.test.tsx
import { render } from '@/test-utils/server-component-wrapper';
test('TeamDashboard loads team data correctly', async () => {
const team = await createMockTeamAccount({
name: 'Test Team',
slug: 'test-team'
});
const { container } = await render(
<TeamDashboard params={{ account: team.slug }} />
);
expect(container).toHaveTextContent('Test Team');
expect(container).toHaveTextContent('Team Dashboard');
// Verify it loaded real data
const recentActivity = container.querySelector('[data-test="recent-activity"]');
expect(recentActivity).toBeTruthy();
});
Testing Client Components with Server Data
// app/home/[account]/_components/team-notes-list.test.tsx
describe('TeamNotesList', () => {
test('allows team members to manage notes', async () => {
const { team, owner } = await setupTestTeam();
// Mock the authenticated user
mockAuthenticatedUser(owner);
render(
<TestProviders>
<TeamNotesList accountId={team.id} />
</TestProviders>
);
// Wait for initial load
await waitFor(() => {
expect(screen.getByText('Team Notes')).toBeInTheDocument();
});
// Test creating a note
await userEvent.click(screen.getByText('Add Note'));
const titleInput = screen.getByLabelText('Title');
await userEvent.type(titleInput, 'New Team Note');
await userEvent.click(screen.getByText('Save'));
// Verify optimistic update
expect(screen.getByText('New Team Note')).toBeInTheDocument();
// Verify server persistence
await waitFor(() => {
expect(screen.getByText('Saved')).toBeInTheDocument();
});
});
});
Testing Forms with Server Actions
// app/home/[account]/settings/_components/team-settings-form.test.tsx
describe('TeamSettingsForm', () => {
test('updates team settings with validation', async () => {
const team = await createMockTeamAccount({
name: 'Original Name',
slug: 'original-slug'
});
render(
<TestProviders>
<TeamSettingsForm account={team} />
</TestProviders>
);
// Test client-side validation
const nameInput = screen.getByLabelText('Team Name');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'A'); // Too short
await userEvent.click(screen.getByText('Save Changes'));
expect(
await screen.findByText('Team name must be at least 2 characters')
).toBeInTheDocument();
// Test successful update
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Team Name');
await userEvent.click(screen.getByText('Save Changes'));
// Verify optimistic UI update
expect(screen.getByDisplayValue('Updated Team Name')).toBeInTheDocument();
// Verify server update
await waitFor(() => {
expect(screen.getByText('Settings updated successfully')).toBeInTheDocument();
});
// Confirm database was updated
const updatedTeam = await getTeamBySlug(team.slug);
expect(updatedTeam.name).toBe('Updated Team Name');
});
});
Testing Authentication Flows
// app/(auth)/_components/login-form.test.tsx
describe('LoginForm', () => {
test('handles MFA flow correctly', async () => {
// Create user with MFA enabled
const user = await createMockUser({
mfaEnabled: true,
email: 'mfa@example.com'
});
render(<LoginForm />);
// Enter credentials
await userEvent.type(screen.getByLabelText('Email'), user.email);
await userEvent.type(screen.getByLabelText('Password'), 'TestPassword123!');
await userEvent.click(screen.getByText('Sign In'));
// Should show MFA input
await waitFor(() => {
expect(screen.getByLabelText('Enter verification code')).toBeInTheDocument();
});
// Enter MFA code
const mfaCode = await getMockMFACode(user.id);
await userEvent.type(screen.getByLabelText('Enter verification code'), mfaCode);
await userEvent.click(screen.getByText('Verify'));
// Should redirect to dashboard
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/home');
});
});
});
Best Practices
1. Use Real Implementations
// ❌ Mocking everything
jest.mock('@kit/supabase/server-client');
jest.mock('@kit/shared/logger');
jest.mock('@kit/billing/stripe');
// ✅ Use real implementations with test data
const testDb = getTestDatabaseClient();
const testLogger = createTestLogger(); // Real logger, test output
const testStripe = createTestStripeClient(); // Real Stripe in test mode
2. Test User Journeys
test('user can complete onboarding flow', async () => {
const user = await createMockUser({ onboarded: false });
// Start at onboarding
render(<OnboardingFlow user={user} />);
// Step 1: Profile setup
await userEvent.type(screen.getByLabelText('Display Name'), 'John Doe');
await userEvent.click(screen.getByText('Next'));
// Step 2: Team creation
await userEvent.type(screen.getByLabelText('Team Name'), 'Acme Corp');
await userEvent.click(screen.getByText('Next'));
// Step 3: Invite members
await userEvent.click(screen.getByText('Skip for now'));
// Verify completion
await waitFor(() => {
expect(screen.getByText('Welcome to your dashboard!')).toBeInTheDocument();
});
// Verify database updates
const updatedUser = await getUser(user.id);
expect(updatedUser.onboarded).toBe(true);
expect(updatedUser.display_name).toBe('John Doe');
});
3. Test Error Recovery
test('handles network errors gracefully', async () => {
const team = await createMockTeamAccount();
render(
<TestProviders>
<TeamNotesList accountId={team.id} />
</TestProviders>
);
// Simulate network failure
server.use(
http.get('/api/notes', () => {
return new HttpResponse(null, { status: 500 });
})
);
// Trigger refetch
await userEvent.click(screen.getByText('Refresh'));
// Should show error state
expect(await screen.findByText('Failed to load notes')).toBeInTheDocument();
expect(screen.getByText('Try Again')).toBeInTheDocument();
// Fix network and retry
server.resetHandlers();
await userEvent.click(screen.getByText('Try Again'));
// Should recover
await waitFor(() => {
expect(screen.queryByText('Failed to load notes')).not.toBeInTheDocument();
expect(screen.getByText('Your Notes')).toBeInTheDocument();
});
});
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: supabase/postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Setup test database
run: |
pnpm supabase:test:start
pnpm supabase:test:reset
- name: Run integration tests
run: pnpm test:integration
env:
TEST_SUPABASE_URL: http://localhost:54321
TEST_SUPABASE_ANON_KEY: ${{ secrets.TEST_SUPABASE_ANON_KEY }}
Remember: Integration tests are your workhorses. They should make up the bulk of your test suite, providing confidence that your application works correctly without the brittleness of E2E tests or the limited scope of unit tests.