javascript-testing-patterns

Comprehensive JavaScript/TypeScript testing patterns for Jest, Vitest, and AdonisJS/Japa. Use when writing tests, reviewing test code, or debugging test failures.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "javascript-testing-patterns" with this command: npx skills add futuregerald/futuregerald-claude-plugin/futuregerald-futuregerald-claude-plugin-javascript-testing-patterns

JavaScript Testing Patterns

Comprehensive testing patterns for modern JavaScript/TypeScript applications.

Core Principle: AAA Pattern

Every test follows Arrange, Act, Assert:

test('calculates total with discount', () => {
  // Arrange - Set up test data
  const cart = { items: [{ price: 100 }], discount: 0.1 }

  // Act - Execute the code under test
  const total = calculateTotal(cart)

  // Assert - Verify the result
  expect(total).toBe(90)
})

Framework Quick Reference

FrameworkRun TestsWatch ModeCoverage
Jestnpm testnpm test -- --watchnpm test -- --coverage
Vitestnpx vitestnpx vitest --watchnpx vitest --coverage
AdonisJS/Japanode ace testN/Anode ace test --coverage

Part 1: Jest/Vitest Patterns

Configuration

Jest (jest.config.js)

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80, statements: 80 },
  },
  setupFilesAfterEnv: ['./jest.setup.ts'],
}

Vitest (vitest.config.ts)

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: { provider: 'v8', reporter: ['text', 'json', 'html'] },
  },
})

Unit Testing Pure Functions

import { describe, it, expect } from 'vitest' // or jest

describe('calculateDiscount', () => {
  it('returns 0 for amounts below threshold', () => {
    expect(calculateDiscount(50)).toBe(0)
  })

  it('applies 10% discount for amounts over 100', () => {
    expect(calculateDiscount(200)).toBe(20)
  })

  it('handles edge case at threshold', () => {
    expect(calculateDiscount(100)).toBe(0)
    expect(calculateDiscount(100.01)).toBeCloseTo(10.001)
  })

  it('throws for negative amounts', () => {
    expect(() => calculateDiscount(-50)).toThrow('Amount cannot be negative')
  })
})

Testing Classes

describe('UserService', () => {
  let service: UserService

  beforeEach(() => {
    service = new UserService()
  })

  it('creates user with valid data', async () => {
    const user = await service.create({ email: 'test@example.com', name: 'Test' })

    expect(user.id).toBeDefined()
    expect(user.email).toBe('test@example.com')
  })

  it('throws for duplicate email', async () => {
    await service.create({ email: 'test@example.com', name: 'First' })

    await expect(service.create({ email: 'test@example.com', name: 'Second' })).rejects.toThrow(
      'Email already exists'
    )
  })
})

Mocking Strategies

Module Mocking

import { vi, describe, it, expect, beforeEach } from 'vitest'
import { sendEmail } from './email-service'
import { UserService } from './user-service'

vi.mock('./email-service', () => ({
  sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}))

describe('UserService with mocked email', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('sends welcome email on registration', async () => {
    const service = new UserService()
    await service.register({ email: 'new@test.com' })

    expect(sendEmail).toHaveBeenCalledWith({
      to: 'new@test.com',
      template: 'welcome',
    })
  })
})

Dependency Injection (Preferred)

interface EmailSender {
  send(to: string, template: string): Promise<void>
}

class UserService {
  constructor(private emailSender: EmailSender) {}

  async register(data: { email: string }) {
    // ... create user
    await this.emailSender.send(data.email, 'welcome')
  }
}

// In tests - easy to mock
describe('UserService', () => {
  it('sends welcome email', async () => {
    const mockSender = { send: vi.fn().mockResolvedValue(undefined) }
    const service = new UserService(mockSender)

    await service.register({ email: 'test@example.com' })

    expect(mockSender.send).toHaveBeenCalledWith('test@example.com', 'welcome')
  })
})

Spying on Methods

it('logs errors to console', async () => {
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

  await service.handleError(new Error('test error'))

  expect(consoleSpy).toHaveBeenCalledWith('Error occurred:', expect.any(Error))
  consoleSpy.mockRestore()
})

Async Testing

describe('API client', () => {
  it('fetches data successfully', async () => {
    const data = await fetchUser(123)
    expect(data.id).toBe(123)
  })

  it('handles timeout', async () => {
    vi.useFakeTimers()

    const promise = fetchWithTimeout('/slow-endpoint', 1000)
    vi.advanceTimersByTime(1500)

    await expect(promise).rejects.toThrow('Request timeout')
    vi.useRealTimers()
  })

  it('retries on failure', async () => {
    const mockFetch = vi
      .fn()
      .mockRejectedValueOnce(new Error('Network error'))
      .mockRejectedValueOnce(new Error('Network error'))
      .mockResolvedValueOnce({ data: 'success' })

    const result = await fetchWithRetry(mockFetch, 3)

    expect(result.data).toBe('success')
    expect(mockFetch).toHaveBeenCalledTimes(3)
  })
})

Integration Testing with Supertest

import request from 'supertest'
import { app } from '../app'
import { db } from '../database'

describe('POST /api/users', () => {
  beforeAll(async () => {
    await db.migrate.latest()
  })

  afterEach(async () => {
    await db('users').truncate()
  })

  afterAll(async () => {
    await db.destroy()
  })

  it('creates user and returns 201', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'test@example.com', password: 'secure123' })
      .expect(201)

    expect(response.body).toMatchObject({
      id: expect.any(Number),
      email: 'test@example.com',
    })
  })

  it('returns 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'invalid', password: 'secure123' })
      .expect(400)

    expect(response.body.errors).toContainEqual(expect.objectContaining({ field: 'email' }))
  })
})

React Component Testing

import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('submits with valid credentials', async () => {
    const onSubmit = vi.fn()
    render(<LoginForm onSubmit={onSubmit} />)

    await userEvent.type(screen.getByLabelText('Email'), 'test@example.com')
    await userEvent.type(screen.getByLabelText('Password'), 'password123')
    await userEvent.click(screen.getByRole('button', { name: /login/i }))

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123'
      })
    })
  })

  it('shows validation error for empty email', async () => {
    render(<LoginForm onSubmit={vi.fn()} />)

    await userEvent.click(screen.getByRole('button', { name: /login/i }))

    expect(screen.getByText('Email is required')).toBeInTheDocument()
  })

  it('disables submit button while loading', async () => {
    render(<LoginForm onSubmit={vi.fn()} isLoading />)

    expect(screen.getByRole('button', { name: /login/i })).toBeDisabled()
  })
})

Testing Custom Hooks

import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('increments count', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(6)
  })

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10))

    act(() => {
      result.current.increment()
      result.current.increment()
      result.current.reset()
    })

    expect(result.current.count).toBe(10)
  })
})

Test Factories with Faker

import { faker } from '@faker-js/faker'

// factories/user.ts
export const createTestUser = (overrides = {}) => ({
  id: faker.string.uuid(),
  email: faker.internet.email(),
  name: faker.person.fullName(),
  createdAt: faker.date.past(),
  ...overrides
})

export const createTestUsers = (count: number, overrides = {}) =>
  Array.from({ length: count }, () => createTestUser(overrides))

// In tests
describe('UserList', () => {
  it('displays all users', () => {
    const users = createTestUsers(5)
    render(<UserList users={users} />)

    users.forEach(user => {
      expect(screen.getByText(user.name)).toBeInTheDocument()
    })
  })
})

Part 2: AdonisJS/Japa Patterns

Running Tests

# Run all tests
node ace test

# Run specific suite
node ace test functional
node ace test unit

# Run specific file
node ace test functional --files="user_auth"

# Run with coverage
node ace test --coverage

Test Structure

import { test } from '@japa/runner'

test.group('Feature | Description', (group) => {
  group.each.setup(() => {
    // runs before each test
  })

  group.each.teardown(() => {
    // runs after each test
  })

  test('specific behavior', async ({ assert }) => {
    const result = someFunction()
    assert.equal(result, expected)
  })
})

Database Testing with Transactions

import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import User from '#models/user'

test.group('Database tests', (group) => {
  // Wrap each test in a transaction that rolls back
  group.each.setup(() => testUtils.db().withGlobalTransaction())

  test('creates a record', async ({ assert }) => {
    const user = await User.create({ email: 'test@example.com' })
    assert.isNotNull(user.id)
    // Transaction rolls back - no cleanup needed
  })
})

HTTP Testing

Basic Request

test.group('API | Users', (group) => {
  group.each.setup(() => testUtils.db().withGlobalTransaction())

  test('GET /users returns list', async ({ client, assert }) => {
    const response = await client.get('/users')

    response.assertStatus(200)
    assert.isArray(response.body())
  })
})

Authenticated Requests

test('authenticated endpoint', async ({ client }) => {
  const user = await User.create({
    /* ... */
  })

  // Web session auth
  const response = await client.get('/dashboard').loginAs(user)

  // API token auth
  const apiResponse = await client.get('/api/v1/me').loginAs(user, 'api')

  response.assertStatus(200)
})

Testing Redirects

test('redirects after action', async ({ client }) => {
  const user = await User.create({
    /* ... */
  })

  const response = await client
    .post('/logout')
    .redirects(0) // Don't follow redirects
    .loginAs(user)

  response.assertStatus(302)
  response.assertHeader('location', '/login')
})

Form and JSON Submissions

// Form data
const response = await client
  .post('/posts')
  .form({ title: 'My Post', description: 'A test post' })
  .loginAs(user)

// JSON API
const response = await client
  .post('/api/v1/posts')
  .json({ title: 'My Post', description: 'A test post' })
  .loginAs(user, 'api')

// AJAX request
const response = await client
  .post('/comments')
  .header('X-Requested-With', 'XMLHttpRequest')
  .form({ content: 'Test comment' })
  .loginAs(user)

Japa Assertions

test('assertions example', async ({ assert }) => {
  // Equality
  assert.equal(actual, expected)
  assert.deepEqual(obj1, obj2)

  // Truthiness
  assert.isTrue(value)
  assert.isFalse(value)
  assert.isNull(value)
  assert.isNotNull(value)

  // Types
  assert.isString(value)
  assert.isArray(value)
  assert.isObject(value)

  // Arrays/Objects
  assert.lengthOf(array, 3)
  assert.include(array, item)
  assert.property(obj, 'key')
  assert.containsSubset(obj, { key: 'value' })

  // Exceptions
  assert.throws(() => throwingFn(), Error)
  await assert.rejects(async () => asyncThrowingFn(), Error)
})

Response Assertions

response.assertStatus(200)
response.assertHeader('content-type', 'application/json')
response.assertHeader('location', '/dashboard')
response.assertBody({ success: true })
response.assertBodyContains({ id: 1 })
response.assertTextIncludes('Welcome')

Testing with Sinon Mocks

import sinon from 'sinon'
import EmailService from '#services/email_service'

test.group('With mocks', (group) => {
  group.each.teardown(() => {
    sinon.restore()
  })

  test('sends email on registration', async ({ assert }) => {
    const sendStub = sinon.stub(EmailService, 'send').resolves()

    await UserService.register({ email: 'test@example.com' })

    assert.isTrue(sendStub.calledOnce)
    assert.equal(sendStub.firstCall.args[0], 'test@example.com')
  })
})

Common Test Patterns

Auth Required Routes

test('requires authentication', async ({ client }) => {
  const response = await client.get('/dashboard').redirects(0)
  response.assertStatus(302)
  response.assertHeader('location', '/login')
})

test('API returns 401 without auth', async ({ client }) => {
  const response = await client.get('/api/v1/me')
  response.assertStatus(401)
})

Validation Errors

test('validates required fields', async ({ client }) => {
  const user = await User.create({
    /* ... */
  })

  const response = await client.post('/api/v1/posts').json({}).loginAs(user, 'api')

  response.assertStatus(422)
  response.assertBodyContains({ code: 'E_VALIDATION' })
})

Authorization

test('denies access to other user resources', async ({ client }) => {
  const owner = await User.create({ email: 'owner@test.com' })
  const other = await User.create({ email: 'other@test.com' })
  const resource = await Resource.create({ ownerId: owner.id })

  const response = await client
    .patch(`/api/v1/resources/${resource.id}`)
    .json({ title: 'Hacked' })
    .loginAs(other, 'api')

  response.assertStatus(403)
})

Anti-Patterns to Avoid

Don't Test Implementation Details

// BAD
test('calls internal method', async () => {
  const spy = vi.spyOn(service, '_internalHelper')
  await service.doThing()
  expect(spy).toHaveBeenCalled()
})

// GOOD - Test observable behavior
test('produces correct output', async () => {
  const result = await service.doThing()
  expect(result).toEqual(expected)
})

Don't Over-Mock

// BAD - Testing mock, not real code
test('calls database', async () => {
  const mockDb = { query: vi.fn().mockResolvedValue([]) }
  const service = new UserService(mockDb)
  await service.getUsers()
  expect(mockDb.query).toHaveBeenCalled()
})

// GOOD - Test real behavior with test database
test('returns users from database', async () => {
  await User.create({ name: 'Test' })
  const users = await service.getUsers()
  expect(users).toHaveLength(1)
})

Don't Forget Cleanup

// BAD - Pollutes database
test.group('Tests', () => {
  test('creates record', async () => {
    await User.create({
      /* ... */
    }) // Persists!
  })
})

// GOOD - Uses transaction rollback
test.group('Tests', (group) => {
  group.each.setup(() => testUtils.db().withGlobalTransaction())

  test('creates record', async () => {
    await User.create({
      /* ... */
    }) // Rolls back
  })
})

File Organization

tests/
├── functional/           # HTTP/integration tests
│   ├── auth.spec.ts
│   ├── users.spec.ts
│   └── api/
│       └── users.spec.ts
├── unit/                 # Unit tests
│   └── services/
│       └── user_service.spec.ts
├── factories/            # Test data factories
│   └── user.ts
└── bootstrap.ts          # Test setup

Quick Reference

ActionJest/VitestAdonisJS/Japa
Run testsnpm testnode ace test
Run filenpm test -- path/to/filenode ace test --files="name"
Coverage--coverage--coverage
Mock functionvi.fn() / jest.fn()sinon.stub()
Spyvi.spyOn()sinon.spy()
Auth requestN/A (manual).loginAs(user)
Don't redirectN/A.redirects(0)
Form data.send().form()
JSON data.send().json()
Assert statusexpect(res.status).toBe(200)response.assertStatus(200)

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

code-simplifier

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

subagent-driven-development

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

programmatic-seo

No summary provided by upstream source.

Repository SourceNeeds Review