Testing

Guide for implementing testing strategies in ShipKit applications, including unit testing, integration testing, end-to-end testing, and testing best practices

Testing Guide

This guide covers testing strategies and best practices for ShipKit applications.

Unit Testing

Component Testing

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

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

  it('handles click events', () => {
    const onClick = jest.fn()
    render(<Button onClick={onClick}>Click me</Button>)

    fireEvent.click(screen.getByText('Click me'))
    expect(onClick).toHaveBeenCalledTimes(1)
  })

  it('applies variant styles', () => {
    render(<Button variant="primary">Primary</Button>)
    expect(screen.getByText('Primary')).toHaveClass('button--primary')
  })

  it('is disabled when loading', () => {
    render(<Button loading>Loading</Button>)
    expect(screen.getByText('Loading')).toBeDisabled()
  })
})

Hook Testing

// src/hooks/__tests__/use-debounce.test.ts
import { renderHook, act } from '@testing-library/react'
import { useDebounce } from '../use-debounce'

describe('useDebounce', () => {
  beforeEach(() => {
    jest.useFakeTimers()
  })

  afterEach(() => {
    jest.useRealTimers()
  })

  it('returns initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('initial', 500))
    expect(result.current).toBe('initial')
  })

  it('debounces value changes', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: 'initial' } }
    )

    rerender({ value: 'changed' })
    expect(result.current).toBe('initial')

    act(() => {
      jest.advanceTimersByTime(500)
    })

    expect(result.current).toBe('changed')
  })
})

Utility Testing

// src/lib/__tests__/format.test.ts
import { formatDate, formatCurrency } from '../format'

describe('formatDate', () => {
  it('formats date to local string', () => {
    const date = new Date('2024-01-01T00:00:00.000Z')
    expect(formatDate(date)).toBe('1/1/2024')
  })

  it('handles invalid dates', () => {
    expect(formatDate(null)).toBe('-')
    expect(formatDate(undefined)).toBe('-')
  })
})

describe('formatCurrency', () => {
  it('formats number to USD', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56')
  })

  it('handles negative numbers', () => {
    expect(formatCurrency(-1234.56)).toBe('-$1,234.56')
  })
})

Integration Testing

API Route Testing

// src/app/api/posts/__tests__/route.test.ts
import { createMocks } from 'node-mocks-http'
import { GET, POST } from '../route'
import { db } from '@/server/db'
import { mockSession } from '@/lib/test/session'

jest.mock('@/server/db')

describe('Posts API', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  describe('GET /api/posts', () => {
    it('returns posts for authenticated users', async () => {
      const { req, res } = createMocks({
        method: 'GET',
      })

      mockSession(req, {
        user: { id: '1', role: 'USER' },
      })

      const posts = [
        { id: '1', title: 'Post 1' },
        { id: '2', title: 'Post 2' },
      ]

      db.post.findMany.mockResolvedValue(posts)

      await GET(req, res)

      expect(res._getStatusCode()).toBe(200)
      expect(JSON.parse(res._getData())).toEqual(posts)
    })

    it('returns 401 for unauthenticated users', async () => {
      const { req, res } = createMocks({
        method: 'GET',
      })

      await GET(req, res)

      expect(res._getStatusCode()).toBe(401)
    })
  })

  describe('POST /api/posts', () => {
    it('creates a post for authenticated users', async () => {
      const { req, res } = createMocks({
        method: 'POST',
        body: {
          title: 'New Post',
          content: 'Content',
        },
      })

      mockSession(req, {
        user: { id: '1', role: 'USER' },
      })

      const post = {
        id: '1',
        title: 'New Post',
        content: 'Content',
      }

      db.post.create.mockResolvedValue(post)

      await POST(req, res)

      expect(res._getStatusCode()).toBe(201)
      expect(JSON.parse(res._getData())).toEqual(post)
    })
  })
})

Database Testing

// src/server/db/__tests__/posts.test.ts
import { db } from '@/server/db'
import {
  createPost,
  getPostById,
  updatePost,
  deletePost,
} from '../posts'

describe('Posts Database Operations', () => {
  beforeEach(async () => {
    await db.$reset()
  })

  it('creates a post', async () => {
    const post = await createPost({
      title: 'Test Post',
      content: 'Content',
      authorId: '1',
    })

    expect(post).toMatchObject({
      title: 'Test Post',
      content: 'Content',
      authorId: '1',
    })
  })

  it('gets a post by id', async () => {
    const created = await createPost({
      title: 'Test Post',
      content: 'Content',
      authorId: '1',
    })

    const post = await getPostById(created.id)
    expect(post).toMatchObject(created)
  })

  it('updates a post', async () => {
    const created = await createPost({
      title: 'Test Post',
      content: 'Content',
      authorId: '1',
    })

    const updated = await updatePost(created.id, {
      title: 'Updated Title',
    })

    expect(updated).toMatchObject({
      ...created,
      title: 'Updated Title',
    })
  })

  it('deletes a post', async () => {
    const created = await createPost({
      title: 'Test Post',
      content: 'Content',
      authorId: '1',
    })

    await deletePost(created.id)
    const post = await getPostById(created.id)
    expect(post).toBeNull()
  })
})

End-to-End Testing

Page Testing

// cypress/e2e/auth.cy.ts
describe('Authentication', () => {
  beforeEach(() => {
    cy.clearCookies()
  })

  it('allows users to sign in', () => {
    cy.visit('/login')

    cy.get('input[name="email"]').type('[email protected]')
    cy.get('input[name="password"]').type('password123')
    cy.get('button[type="submit"]').click()

    cy.url().should('include', '/dashboard')
    cy.get('h1').should('contain', 'Dashboard')
  })

  it('shows error for invalid credentials', () => {
    cy.visit('/login')

    cy.get('input[name="email"]').type('[email protected]')
    cy.get('input[name="password"]').type('wrongpassword')
    cy.get('button[type="submit"]').click()

    cy.get('[data-testid="error-message"]').should('be.visible')
    cy.url().should('include', '/login')
  })

  it('allows users to sign out', () => {
    cy.login('[email protected]', 'password123')
    cy.visit('/dashboard')

    cy.get('[data-testid="user-menu"]').click()
    cy.get('[data-testid="sign-out"]').click()

    cy.url().should('equal', Cypress.config().baseUrl + '/')
  })
})

Form Testing

// cypress/e2e/posts.cy.ts
describe('Posts', () => {
  beforeEach(() => {
    cy.login('[email protected]', 'password123')
  })

  it('allows creating a new post', () => {
    cy.visit('/dashboard/posts/new')

    cy.get('input[name="title"]').type('Test Post')
    cy.get('textarea[name="content"]').type('Post content')
    cy.get('button[type="submit"]').click()

    cy.url().should('match', /\/posts\/[\w-]+/)
    cy.get('h1').should('contain', 'Test Post')
  })

  it('validates required fields', () => {
    cy.visit('/dashboard/posts/new')

    cy.get('button[type="submit"]').click()

    cy.get('[data-testid="title-error"]')
      .should('be.visible')
      .and('contain', 'Title is required')

    cy.get('[data-testid="content-error"]')
      .should('be.visible')
      .and('contain', 'Content is required')
  })

  it('allows editing a post', () => {
    cy.createPost({
      title: 'Original Title',
      content: 'Original content',
    }).then((post) => {
      cy.visit(`/dashboard/posts/${post.id}/edit`)

      cy.get('input[name="title"]')
        .clear()
        .type('Updated Title')
      cy.get('textarea[name="content"]')
        .clear()
        .type('Updated content')
      cy.get('button[type="submit"]').click()

      cy.url().should('include', `/posts/${post.id}`)
      cy.get('h1').should('contain', 'Updated Title')
    })
  })
})

Test Setup

Jest Configuration

// jest.config.mjs
import nextJest from 'next/jest.js'

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

/** @type {import('jest').Config} */
const config = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
}

export default createJestConfig(config)

Cypress Configuration

// cypress.config.ts
import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false,
    screenshotOnRunFailure: false,
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
  component: {
    devServer: {
      framework: 'next',
      bundler: 'webpack',
    },
  },
})

Testing Utilities

Test Helpers

// src/lib/test/helpers.ts
import { type Session } from 'next-auth'
import { db } from '@/server/db'

export function createTestUser(data: Partial<User> = {}) {
  return db.user.create({
    data: {
      email: '[email protected]',
      name: 'Test User',
      ...data,
    },
  })
}

export function createTestSession(
  user: User,
  expires: Date = new Date(Date.now() + 24 * 60 * 60 * 1000)
): Session {
  return {
    user: {
      id: user.id,
      email: user.email,
      name: user.name,
    },
    expires: expires.toISOString(),
  }
}

export function mockFetch(data: any) {
  global.fetch = jest.fn().mockImplementation(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve(data),
    })
  )
}

export function mockError(status: number, message: string) {
  global.fetch = jest.fn().mockImplementation(() =>
    Promise.resolve({
      ok: false,
      status,
      json: () => Promise.resolve({ error: message }),
    })
  )
}

Custom Matchers

// src/lib/test/matchers.ts
import { expect } from '@jest/globals'

expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling
    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      }
    } else {
      return {
        message: () =>
          `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      }
    }
  },

  toHaveBeenCalledWithMatch(
    received: jest.Mock,
    ...expectedArgs: unknown[]
  ) {
    const calls = received.mock.calls
    const pass = calls.some((call) =>
      expectedArgs.every((arg, i) => {
        if (typeof arg === 'object') {
          return expect.objectContaining(arg as object).asymmetricMatch(
            call[i]
          )
        }
        return arg === call[i]
      })
    )

    return {
      pass,
      message: () =>
        pass
          ? `expected ${received.getMockName()} not to have been called with match ${expectedArgs}`
          : `expected ${received.getMockName()} to have been called with match ${expectedArgs}`,
    }
  },
})

Testing Best Practices

  1. Test Organization

    • Group related tests
    • Use descriptive names
    • Follow AAA pattern
    • Keep tests focused
  2. Test Coverage

    • Aim for high coverage
    • Test edge cases
    • Test error scenarios
    • Test async behavior
  3. Test Maintenance

    • Keep tests simple
    • Avoid test duplication
    • Use test utilities
    • Update tests with code
  4. Performance

    • Mock external services
    • Use test databases
    • Optimize test runs
    • Parallelize when possible

Testing Checklist

  1. Unit Tests

    • [ ] Component tests
    • [ ] Hook tests
    • [ ] Utility tests
    • [ ] Error handling
  2. Integration Tests

    • [ ] API route tests
    • [ ] Database tests
    • [ ] Service tests
    • [ ] Authentication
  3. E2E Tests

    • [ ] User flows
    • [ ] Form submissions
    • [ ] Navigation
    • [ ] Error states
  4. Test Quality

    • [ ] Coverage goals
    • [ ] Documentation
    • [ ] Maintainability
    • [ ] Performance