Developer Guide

Testing Next.js + Supabase: Unit, Integration, RLS, E2E

Four layers that catch real bugs: Jest units, integration on local Supabase, pgTAP for RLS policies, Playwright E2E — plus the CI to run them all.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
38 min read

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#

Per the official Next.js Jest guide, next/jest "automatically configures Jest for you," handling the Next.js Compiler transform, CSS/image mocking, and .env loading — the jest.config.js below builds on that default setup.

Install Dependencies#

bash
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/dom @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#

javascript
// 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#

javascript
// 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#

typescript
// 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#

typescript
// 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#

typescript
// 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#

typescript
// 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#

bash
npx supabase start

Integration Test with Real Database#

typescript
// 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#

Per Supabase's Row Level Security guide, policies can be tested by switching roles with SET ROLE or by signing in as different test users and asserting on what each one can read or write — the approach used below.

RLS Policy Test#

typescript
// 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 { data, error } = await user2Client
      .from('posts')
      .update({ title: 'Hacked!' })
      .eq('id', post!.id)
      .select()
 
    // RLS hides the row: the update matches nothing, it does not raise an error
    expect(error).toBeNull()
    expect(data).toEqual([])
  })
})

An UPDATE blocked by a USING clause is not an error. PostgreSQL filters the rows the policy hides before updating, so the statement succeeds and touches nothing; per the CREATE POLICY reference, a row for which USING is false is not visible, and "typically, such rows are silently suppressed; no error is reported". On PostgreSQL 17.11 (official Docker image, 13 October 2026), the same update run as another user returned UPDATE 0 and the row kept its title. A test that expects error to be set therefore fails on a correct policy, and toBeDefined() passes on null, so it never failed at all. Assert on the returned rows instead.

RLS tests in SQL with pgTAP#

The client tests above go through the API. Supabase also documents testing policies inside the database with pgTAP, run by the CLI against the local stack (Testing overview). Each test file runs in a transaction that is rolled back, so tests do not leave data behind. Create one with supabase test new todos_rls.test, then switch role and user inside it:

sql
begin;
create extension if not exists pgtap with schema extensions;
select plan(2);
 
insert into auth.users (id, email) values
  ('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'),
  ('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com');
 
insert into public.todos (task, user_id) values
  ('User 1 Task 1', '123e4567-e89b-12d3-a456-426614174000');
 
-- as User 2
set local role authenticated;
set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234';
 
select results_eq(
  'select count(*) from todos',
  ARRAY[0::bigint],
  'User 2 sees none of User 1''s todos'
);
 
select results_ne(
  $$ update todos set task = 'Hacked!' where user_id = '123e4567-e89b-12d3-a456-426614174000'::uuid returning 1 $$,
  $$ values(1) $$,
  'User 2 cannot modify User 1 todos'
);
 
select * from finish();
rollback;

This assumes the todos table and the policy using ((select auth.uid()) = user_id) for the authenticated role from the Supabase testing overview; swap in your own table and ids. Run every file under supabase/tests with:

bash
supabase test db

The update check uses the same idea as the client test: returning 1 yields no row when the policy hides the target, so results_ne passes only when nothing was updated.

7. End-to-End Testing with Playwright#

The official Next.js Playwright guide recommends running E2E tests "against your production code to more closely resemble how your application will behave" — next build && next start before npx playwright test, or Playwright's webServer option to manage that lifecycle for you.

Playwright Configuration#

typescript
// 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 build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

E2E Test Example#

typescript
// 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#

typescript
// 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)#

MSW v2 replaced the rest API with http and HttpResponse. If you're following an older tutorial or copying from an existing codebase, check the version — rest.get(...) handlers from MSW v1 do not work with MSW v2's request listener signature. See the MSW migration guide for the full list of breaking changes.

typescript
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'
 
export const handlers = [
  http.post('https://your-project.supabase.co/auth/v1/token', () => {
    return HttpResponse.json({
      access_token: 'mock-token',
      user: {
        id: 'mock-user-id',
        email: 'test@example.com',
      },
    })
  }),
 
  http.get('https://your-project.supabase.co/rest/v1/posts', () => {
    return HttpResponse.json([
      { id: '1', title: 'Post 1' },
      { id: '2', title: 'Post 2' },
    ])
  }),
]
typescript
// mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
 
export const server = setupServer(...handlers)
typescript
// jest.setup.js
import { server } from './mocks/server'
 
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

9. CI/CD Integration#

GitHub Actions Workflow#

yaml
# .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: Apply migrations to the local stack
        run: npx supabase db push --local
 
      - name: Run RLS tests (pgTAP)
        run: npx supabase test db
 
      - 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@v4
        with:
          name: test-results
          path: |
            coverage/
            playwright-report/

10. Test Coverage#

Generate Coverage Report#

bash
npm test -- --coverage

Coverage Configuration#

javascript
// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
    '!src/**/__tests__/**',
  ],
  coverageThreshold: {
    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#

typescript
// ❌ Bad: Testing implementation
expect(component.state.count).toBe(1)
 
// ✅ Good: Testing behavior
expect(screen.getByText('Count: 1')).toBeInTheDocument()

Don't Forget Cleanup#

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

Mock External Dependencies#

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

Frequently Asked Questions (FAQ)#

How do I test Next.js Server Components?#

Synchronous Server Components can be unit-tested with Jest and React Testing Library. Async Server Components cannot: the Next.js Jest guide says "Jest currently does not support them" and recommends E2E tests, so cover any component that awaits Supabase data with Playwright (section 7).

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). For the corresponding server-side logic, consult the complete Next.js Server Actions guide.

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. For the wider design choices that are hard to undo later, see architecture decisions to avoid.

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 run the whole suite in CI with the workflow from section 9. More Next.js + Supabase guides are collected in the Next.js + Supabase hub. With these strategies, you'll ship with confidence.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.