Testing Strategies for Next.js and Supabase Applications
Developer Guide

Testing Strategies for Next.js and Supabase Applications

Comprehensive guide to testing Next.js and Supabase applications. Learn unit testing, integration testing, E2E testing, RLS policy testing, mocking strategies, and CI/CD integration.

2026-02-25
38 min read
Testing Strategies for Next.js and Supabase Applications

Testing Strategies for Next.js and Supabase Applications#

Testing is essential for building reliable applications. This comprehensive guide covers testing strategies for Next.js and Supabase applications, from unit tests to end-to-end testing, including database testing and CI/CD integration.

Why Testing Matters#

Confidence:

  • Deploy without fear
  • Refactor safely
  • Catch bugs early
  • Document behavior

Quality:

  • Prevent regressions
  • Ensure edge cases work
  • Validate business logic
  • Maintain code quality

Speed:

  • Faster development cycles
  • Automated validation
  • Quick feedback loops
  • Reduced manual testing

1. Testing Setup#

Install Dependencies#

npm install --save-dev jest @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
npm install --save-dev @playwright/test
npm install --save-dev msw

Jest Configuration#

// jest.config.js
const nextJest = require('next/jest')

const createJestConfig = nextJest({
  dir: './',
})

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
  ],
}

module.exports = createJestConfig(customJestConfig)

Jest Setup#

// jest.setup.js
import '@testing-library/jest-dom'

// Mock Next.js router
jest.mock('next/navigation', () => ({
  useRouter() {
    return {
      push: jest.fn(),
      replace: jest.fn(),
      prefetch: jest.fn(),
      back: jest.fn(),
      pathname: '/',
      query: {},
      asPath: '/',
    }
  },
  useSearchParams() {
    return new URLSearchParams()
  },
  usePathname() {
    return '/'
  },
}))

2. Unit Testing Components#

Basic Component Test#

// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'

describe('Button', () => {
  it('renders button with text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByText('Click me')).toBeInTheDocument()
  })

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click me</Button>)
    
    fireEvent.click(screen.getByText('Click me'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>)
    expect(screen.getByText('Click me')).toBeDisabled()
  })
})

Testing with Supabase#

// components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './UserProfile'
import { createClient } from '@/lib/supabase/client'

// Mock Supabase client
jest.mock('@/lib/supabase/client', () => ({
  createClient: jest.fn(),
}))

describe('UserProfile', () => {
  it('displays user data', async () => {
    const mockSupabase = {
      from: jest.fn().mockReturnThis(),
      select: jest.fn().mockReturnThis(),
      eq: jest.fn().mockReturnThis(),
      single: jest.fn().mockResolvedValue({
        data: {
          id: '1',
          name: 'John Doe',
          email: 'john@example.com',
        },
        error: null,
      }),
    }

    ;(createClient as jest.Mock).mockReturnValue(mockSupabase)

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument()
      expect(screen.getByText('john@example.com')).toBeInTheDocument()
    })
  })

  it('displays error message on fetch failure', async () => {
    const mockSupabase = {
      from: jest.fn().mockReturnThis(),
      select: jest.fn().mockReturnThis(),
      eq: jest.fn().mockReturnThis(),
      single: jest.fn().mockResolvedValue({
        data: null,
        error: { message: 'User not found' },
      }),
    }

    ;(createClient as jest.Mock).mockReturnValue(mockSupabase)

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument()
    })
  })
})

3. Testing Server Actions#

Server Action Test#

// app/actions/posts.test.ts
import { createPost } from './posts'
import { createClient } from '@/lib/supabase/server'

jest.mock('@/lib/supabase/server')

describe('createPost', () => {
  it('creates a post successfully', async () => {
    const mockSupabase = {
      auth: {
        getUser: jest.fn().mockResolvedValue({
          data: { user: { id: 'user-1' } },
          error: null,
        }),
      },
      from: jest.fn().mockReturnThis(),
      insert: jest.fn().mockReturnThis(),
      select: jest.fn().mockReturnThis(),
      single: jest.fn().mockResolvedValue({
        data: {
          id: 'post-1',
          title: 'Test Post',
          content: 'Test content',
        },
        error: null,
      }),
    }

    ;(createClient as jest.Mock).mockReturnValue(mockSupabase)

    const formData = new FormData()
    formData.append('title', 'Test Post')
    formData.append('content', 'Test content')

    const result = await createPost(formData)

    expect(result.data).toEqual({
      id: 'post-1',
      title: 'Test Post',
      content: 'Test content',
    })
    expect(result.error).toBeNull()
  })

  it('returns error when user is not authenticated', async () => {
    const mockSupabase = {
      auth: {
        getUser: jest.fn().mockResolvedValue({
          data: { user: null },
          error: null,
        }),
      },
    }

    ;(createClient as jest.Mock).mockReturnValue(mockSupabase)

    const formData = new FormData()
    formData.append('title', 'Test Post')

    const result = await createPost(formData)

    expect(result.error).toBe('Unauthorized')
  })
})

4. Integration Testing#

API Route Testing#

// app/api/posts/route.test.ts
import { GET, POST } from './route'
import { createClient } from '@/lib/supabase/server'

jest.mock('@/lib/supabase/server')

describe('/api/posts', () => {
  describe('GET', () => {
    it('returns list of posts', async () => {
      const mockSupabase = {
        from: jest.fn().mockReturnThis(),
        select: jest.fn().mockReturnThis(),
        order: jest.fn().mockResolvedValue({
          data: [
            { id: '1', title: 'Post 1' },
            { id: '2', title: 'Post 2' },
          ],
          error: null,
        }),
      }

      ;(createClient as jest.Mock).mockReturnValue(mockSupabase)

      const request = new Request('http://localhost:3000/api/posts')
      const response = await GET(request)
      const data = await response.json()

      expect(response.status).toBe(200)
      expect(data.data).toHaveLength(2)
    })
  })

  describe('POST', () => {
    it('creates a new post', async () => {
      const mockSupabase = {
        auth: {
          getUser: jest.fn().mockResolvedValue({
            data: { user: { id: 'user-1' } },
            error: null,
          }),
        },
        from: jest.fn().mockReturnThis(),
        insert: jest.fn().mockReturnThis(),
        select: jest.fn().mockReturnThis(),
        single: jest.fn().mockResolvedValue({
          data: { id: '1', title: 'New Post' },
          error: null,
        }),
      }

      ;(createClient as jest.Mock).mockReturnValue(mockSupabase)

      const request = new Request('http://localhost:3000/api/posts', {
        method: 'POST',
        body: JSON.stringify({ title: 'New Post', content: 'Content' }),
      })

      const response = await POST(request)
      const data = await response.json()

      expect(response.status).toBe(201)
      expect(data.data.title).toBe('New Post')
    })
  })
})

5. Testing with Local Supabase#

Setup Local Supabase#

npx supabase start

Integration Test with Real Database#

// tests/integration/posts.test.ts
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

describe('Posts Integration', () => {
  let testUserId: string

  beforeAll(async () => {
    // Create test user
    const { data: { user } } = await supabase.auth.admin.createUser({
      email: 'test@example.com',
      password: 'password123',
      email_confirm: true,
    })
    testUserId = user!.id
  })

  afterAll(async () => {
    // Cleanup
    await supabase.auth.admin.deleteUser(testUserId)
  })

  beforeEach(async () => {
    // Clear posts table
    await supabase.from('posts').delete().neq('id', '00000000-0000-0000-0000-000000000000')
  })

  it('creates and retrieves a post', async () => {
    // Create post
    const { data: post, error: createError } = await supabase
      .from('posts')
      .insert({
        title: 'Test Post',
        content: 'Test content',
        user_id: testUserId,
      })
      .select()
      .single()

    expect(createError).toBeNull()
    expect(post).toBeDefined()

    // Retrieve post
    const { data: retrieved, error: getError } = await supabase
      .from('posts')
      .select('*')
      .eq('id', post!.id)
      .single()

    expect(getError).toBeNull()
    expect(retrieved?.title).toBe('Test Post')
  })
})

6. Testing RLS Policies#

RLS Policy Test#

// tests/rls/posts.test.ts
import { createClient } from '@supabase/supabase-js'

describe('Posts RLS Policies', () => {
  let supabase: any
  let user1Id: string
  let user2Id: string

  beforeAll(async () => {
    // Create test users
    const adminClient = createClient(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_SERVICE_ROLE_KEY!
    )

    const { data: { user: user1 } } = await adminClient.auth.admin.createUser({
      email: 'user1@example.com',
      password: 'password123',
      email_confirm: true,
    })
    user1Id = user1!.id

    const { data: { user: user2 } } = await adminClient.auth.admin.createUser({
      email: 'user2@example.com',
      password: 'password123',
      email_confirm: true,
    })
    user2Id = user2!.id
  })

  it('users can only read their own posts', async () => {
    // Sign in as user1
    const user1Client = createClient(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!
    )
    await user1Client.auth.signInWithPassword({
      email: 'user1@example.com',
      password: 'password123',
    })

    // Create post as user1
    await user1Client.from('posts').insert({
      title: 'User 1 Post',
      user_id: user1Id,
    })

    // Sign in as user2
    const user2Client = createClient(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!
    )
    await user2Client.auth.signInWithPassword({
      email: 'user2@example.com',
      password: 'password123',
    })

    // Try to read user1's posts as user2
    const { data, error } = await user2Client
      .from('posts')
      .select('*')
      .eq('user_id', user1Id)

    // Should return empty array (RLS blocks access)
    expect(data).toEqual([])
  })

  it('users cannot update other users posts', async () => {
    const user1Client = createClient(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!
    )
    await user1Client.auth.signInWithPassword({
      email: 'user1@example.com',
      password: 'password123',
    })

    // Create post as user1
    const { data: post } = await user1Client.from('posts').insert({
      title: 'User 1 Post',
      user_id: user1Id,
    }).select().single()

    // Sign in as user2
    const user2Client = createClient(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!
    )
    await user2Client.auth.signInWithPassword({
      email: 'user2@example.com',
      password: 'password123',
    })

    // Try to update user1's post as user2
    const { error } = await user2Client
      .from('posts')
      .update({ title: 'Hacked!' })
      .eq('id', post!.id)

    // Should fail due to RLS
    expect(error).toBeDefined()
  })
})

7. End-to-End Testing with Playwright#

Playwright Configuration#

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './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:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

E2E Test Example#

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
  test('user can sign up', async ({ page }) => {
    await page.goto('/auth/signup')

    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')

    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('text=Welcome')).toBeVisible()
  })

  test('user can sign in', async ({ page }) => {
    await page.goto('/auth/login')

    await page.fill('input[name="email"]', 'existing@example.com')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')

    await expect(page).toHaveURL('/dashboard')
  })

  test('user can sign out', async ({ page }) => {
    // Sign in first
    await page.goto('/auth/login')
    await page.fill('input[name="email"]', 'existing@example.com')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')

    // Sign out
    await page.click('button:has-text("Sign Out")')
    await expect(page).toHaveURL('/auth/login')
  })
})

Testing CRUD Operations#

// e2e/posts.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Posts', () => {
  test.beforeEach(async ({ page }) => {
    // Sign in before each test
    await page.goto('/auth/login')
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')
  })

  test('user can create a post', async ({ page }) => {
    await page.goto('/posts/new')

    await page.fill('input[name="title"]', 'Test Post')
    await page.fill('textarea[name="content"]', 'Test content')
    await page.click('button[type="submit"]')

    await expect(page.locator('text=Test Post')).toBeVisible()
  })

  test('user can edit their post', async ({ page }) => {
    await page.goto('/posts')
    await page.click('text=Test Post')
    await page.click('button:has-text("Edit")')

    await page.fill('input[name="title"]', 'Updated Post')
    await page.click('button[type="submit"]')

    await expect(page.locator('text=Updated Post')).toBeVisible()
  })

  test('user can delete their post', async ({ page }) => {
    await page.goto('/posts')
    await page.click('text=Updated Post')
    await page.click('button:has-text("Delete")')
    await page.click('button:has-text("Confirm")')

    await expect(page.locator('text=Updated Post')).not.toBeVisible()
  })
})

8. Mocking Strategies#

Mock Service Worker (MSW)#

// mocks/handlers.ts
import { rest } from 'msw'

export const handlers = [
  rest.post('https://your-project.supabase.co/auth/v1/token', (req, res, ctx) => {
    return res(
      ctx.json({
        access_token: 'mock-token',
        user: {
          id: 'mock-user-id',
          email: 'test@example.com',
        },
      })
    )
  }),

  rest.get('https://your-project.supabase.co/rest/v1/posts', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: '1', title: 'Post 1' },
        { id: '2', title: 'Post 2' },
      ])
    )
  }),
]
// mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// jest.setup.js
import { server } from './mocks/server'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

9. CI/CD Integration#

GitHub Actions Workflow#

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: supabase/postgres:15.1.0.117
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Setup Supabase CLI
        uses: supabase/setup-cli@v1

      - name: Start Supabase
        run: npx supabase start

      - name: Run migrations
        run: npx supabase db push

      - name: Run unit tests
        run: npm test

      - name: Run integration tests
        run: npm run test:integration

      - name: Install Playwright
        run: npx playwright install --with-deps

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: |
            coverage/
            playwright-report/

10. Test Coverage#

Generate Coverage Report#

npm test -- --coverage

Coverage Configuration#

// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
    '!src/**/__tests__/**',
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
}

11. Best Practices#

Test Organization#

  • Group related tests with describe
  • Use descriptive test names
  • Follow AAA pattern (Arrange, Act, Assert)
  • Keep tests independent
  • Clean up after tests

What to Test#

  • User interactions
  • Edge cases
  • Error handling
  • Business logic
  • RLS policies
  • API endpoints

What Not to Test#

  • Third-party libraries
  • Framework internals
  • Trivial code
  • Implementation details

12. Common Pitfalls#

Avoid Testing Implementation Details#

// ❌ Bad: Testing implementation
expect(component.state.count).toBe(1)

// ✅ Good: Testing behavior
expect(screen.getByText('Count: 1')).toBeInTheDocument()

Don't Forget Cleanup#

// ✅ Good: Clean up after tests
afterEach(async () => {
  await supabase.from('posts').delete().neq('id', '00000000-0000-0000-0000-000000000000')
})

Mock External Dependencies#

// ✅ Good: Mock external API calls
jest.mock('stripe', () => ({
  Stripe: jest.fn().mockImplementation(() => ({
    checkout: {
      sessions: {
        create: jest.fn().mockResolvedValue({ id: 'session-id' }),
      },
    },
  })),
}))

FAQ#

What testing framework should I use for Next.js and Supabase?#

Use Jest with React Testing Library for unit and integration tests, and Playwright for end-to-end tests. Jest handles component and API route testing, while Playwright simulates real user interactions in a browser.

How do I test Supabase queries without hitting the database?#

Mock the Supabase client using Jest: jest.mock('@/lib/supabase/client'). Return mock data for your queries. For integration tests, use a local Supabase instance with npx supabase start.

Should I test RLS policies?#

Absolutely! RLS policies are critical for security. Test them by creating test users, signing in as different users, and verifying they can only access authorized data. Use SET request.jwt.claim.sub = 'user-id' to test as specific users.

How do I test Server Components?#

Server Components are async functions, so test them like regular async functions. Mock the Supabase server client and verify the component returns the expected JSX. Use @testing-library/react for rendering.

What's the difference between unit and integration tests?#

Unit tests test individual components or functions in isolation with mocked dependencies. Integration tests test how multiple parts work together, often with a real database. Both are important for comprehensive coverage.

How do I test authentication flows?#

Use Playwright for E2E auth testing. Create test users in beforeAll, test sign-up/sign-in/sign-out flows, and verify redirects. For unit tests, mock the auth methods and verify they're called correctly.

Should I test in production?#

Never test destructive operations in production! Use staging environments that mirror production. For monitoring, use synthetic tests that perform read-only operations to verify availability.

How do I test real-time subscriptions?#

Mock the channel subscription in unit tests. For integration tests, use a local Supabase instance, subscribe to changes, trigger database updates, and verify your component receives the updates.

What test coverage should I aim for?#

Aim for 80% code coverage as a baseline. Focus on critical paths (auth, payments, data mutations) with higher coverage. Don't chase 100% - some code (like type definitions) doesn't need tests.

How do I speed up slow tests?#

Run tests in parallel, use test databases with minimal data, mock external API calls, and use beforeAll instead of beforeEach for expensive setup. Consider splitting test suites by speed.

How do I test Edge Functions?#

Test Edge Functions locally with npx supabase functions serve. Write integration tests that call the function endpoint and verify responses. Mock external services (Stripe, SendGrid) in tests.

Should I test error handling?#

Yes! Test both happy paths and error cases. Verify your app handles network failures, invalid input, unauthorized access, and database errors gracefully. Error handling is often where bugs hide.

Frequently Asked Questions (FAQ)#

How do I test Next.js Server Components?#

Test Server Components by importing them directly in your test files and using React Testing Library. Mock the Supabase server client and any async data fetching. Server Components are just async functions that return JSX, so they're straightforward to test.

Should I test RLS policies?#

Yes, absolutely! RLS policies are critical security features. Test them by creating test users, signing in as different users, and verifying they can only access authorized data. Use the local Supabase instance for RLS testing.

How do I mock Supabase in tests?#

Use Jest mocks: jest.mock('@/lib/supabase/client') and provide mock implementations of Supabase methods. For integration tests, use a local Supabase instance with npx supabase start instead of mocking.

What's the difference between unit and integration tests?#

Unit tests test individual components/functions in isolation with mocked dependencies. Integration tests test how multiple parts work together (API routes + database, components + Supabase client) using real or test databases.

How do I test authentication flows?#

Use Playwright for E2E auth testing. Create test users in beforeAll, test sign up/sign in/sign out flows, and verify protected routes redirect correctly. For unit tests, mock the Supabase auth client.

Can I use the production database for testing?#

Never test against production! Use local Supabase (npx supabase start) for development testing, and a separate test database for CI/CD. Always clean up test data after tests complete.

How do I test Edge Functions?#

Test Edge Functions locally with npx supabase functions serve, then use curl or fetch to call them. For unit tests, extract business logic into separate functions that can be tested independently.

What test coverage should I aim for?#

Aim for 80%+ coverage on critical business logic, authentication, and data access layers. Don't obsess over 100% coverage—focus on testing important user flows and edge cases rather than trivial code.

How do I test real-time subscriptions?#

Mock the Supabase channel and subscription methods in unit tests. For integration tests, use local Supabase and trigger actual database changes to verify subscriptions receive updates correctly.

Should I test third-party integrations?#

Mock third-party APIs (Stripe, SendGrid, etc.) in unit tests using MSW (Mock Service Worker). For integration tests, use test mode APIs provided by the service (e.g., Stripe test mode).

How do I run tests in CI/CD?#

Use GitHub Actions with Supabase CLI. Start local Supabase in CI, run migrations, execute tests, and upload coverage reports. See the GitHub Actions workflow example in the guide.

What's the best way to test Server Actions?#

Test Server Actions like regular async functions. Mock the Supabase server client, create FormData objects with test data, call the action, and assert on the returned result. Test both success and error cases.

How do I test file uploads?#

Create mock File or Blob objects in tests, test the upload logic with mocked Supabase Storage methods, and verify the correct storage bucket and file path are used. For E2E tests, use Playwright to upload actual files.

Should I test database migrations?#

Yes, test migrations by applying them to a test database and verifying schema changes. Test both up and down migrations. Ensure data migrations preserve existing data correctly.

Conclusion#

Comprehensive testing ensures your Next.js and Supabase application is reliable and maintainable. Start with unit tests for components and functions, add integration tests for API routes and database operations, and use E2E tests for critical user flows.

Test RLS policies thoroughly, mock external dependencies appropriately, and integrate testing into your CI/CD pipeline. With these strategies, you'll ship with confidence.

Frequently Asked Questions

|

Have more questions? Contact us