Skip to content

Latest commit

 

History

History
528 lines (392 loc) · 13.2 KB

File metadata and controls

528 lines (392 loc) · 13.2 KB

Unit Testing Guide for Frontend Applications in NX Workspace with Jest

Table of Contents

  1. Overview
  2. Prerequisites
  3. NX Workspace Testing Setup
  4. Jest Configuration
  5. Frontend Testing Types
  6. Step-by-Step Implementation
  7. Testing Best Practices
  8. Benefits of Unit Testing
  9. Limitations and Considerations
  10. Troubleshooting
  11. Conclusion

Overview

This document provides a comprehensive guide for implementing unit testing in NX workspace using Jest, specifically focused on frontend applications. NX provides excellent built-in support for Jest testing with optimized configurations and parallel execution capabilities.

Prerequisites

  • NX workspace (version 20.2.2 or higher)
  • Node.js (version 18 or higher)
  • TypeScript support
  • React/Next.js applications
  • Basic understanding of unit testing concepts

NX Workspace Testing Setup

1. NX Jest Plugin Configuration

NX automatically configures Jest through the @nx/jest/plugin in your nx.json:

{
  "plugins": [
    {
      "plugin": "@nx/jest/plugin",
      "options": {
        "targetName": "test"
      }
    }
  ]
}

2. Workspace-Level Jest Preset

The workspace uses a centralized Jest preset (jest.preset.js):

const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };

3. Project-Level Jest Configuration

Each project has its own jest.config.ts that extends the workspace preset:

export default {
  displayName: 'project-name',
  preset: '../../jest.preset.js',
  transform: {
    '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
    '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/next/babel'] }],
  },
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
  coverageDirectory: '../../coverage/project-name',
};

Jest Configuration

Key Configuration Options

const config: Config = {
  // Clear mocks between tests
  clearMocks: true,

  // Enable coverage collection
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageProvider: 'v8',

  // Test environment for React components
  testEnvironment: 'jsdom',

  // Setup files for test environment
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

  // Module name mapping for path aliases
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },

  // Transform configuration
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
};

Required Dependencies

{
  "devDependencies": {
    "@nx/jest": "20.2.2",
    "@testing-library/jest-dom": "^6.4.6",
    "@testing-library/react": "15.0.6",
    "@testing-library/user-event": "^14.5.2",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "ts-jest": "^29.1.0",
    "@types/jest": "^29.5.12"
  }
}

Frontend Testing Types

1. Component Testing

  • Purpose: Test React components in isolation
  • Tools: React Testing Library, Jest
  • What to test: Rendering, props, user interactions, state changes

2. Hook Testing

  • Purpose: Test custom React hooks
  • Tools: @testing-library/react-hooks
  • What to test: Hook behavior, state updates, side effects

3. Utility Function Testing

  • Purpose: Test pure functions and utilities
  • Tools: Jest
  • What to test: Input/output, edge cases, error handling

4. Service/API Testing

  • Purpose: Test API calls and data fetching
  • Tools: Jest with mocking
  • What to test: API calls, error handling, data transformation

5. Integration Testing

  • Purpose: Test component interactions
  • Tools: React Testing Library
  • What to test: Component communication, user workflows

Step-by-Step Implementation

Step 1: Create Test Setup File

Create jest.setup.ts in your project root:

import '@testing-library/jest-dom';

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  disconnect() {}
  observe() {}
  unobserve() {}
};

Step 2: Create Test Utilities

Create src/test-utils.tsx:

import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// Mock providers for testing
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  });

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};

const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

Step 3: Write Component Tests

Example component test (Button.test.tsx):

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button Component', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('applies disabled state correctly', () => {
    render(<Button disabled>Disabled button</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Step 4: Write Hook Tests

Example hook test (useCounter.test.ts):

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

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

  it('should increment count', () => {
    const { result } = renderHook(() => useCounter());

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

    expect(result.current.count).toBe(1);
  });
});

Step 5: Write API Service Tests

Example API test (api.test.ts):

import { fetchUserData } from './api';
import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/users/:id', (req, res, ctx) => {
    return res(ctx.json({ id: '1', name: 'John Doe' }));
  })
);

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

describe('API Service', () => {
  it('fetches user data successfully', async () => {
    const userData = await fetchUserData('1');
    expect(userData).toEqual({ id: '1', name: 'John Doe' });
  });
});

Step 6: Run Tests

# Run all tests
nx test

# Run tests for specific project
nx test project-name

# Run tests in watch mode
nx test project-name --watch

# Run tests with coverage
nx test project-name --coverage

# Run tests in CI mode
nx test project-name --ci

Testing Best Practices

1. Test Structure (AAA Pattern)

describe('ComponentName', () => {
  it('should do something specific', () => {
    // Arrange - Set up test data and mocks
    const mockProps = { title: 'Test Title' };

    // Act - Execute the function or render component
    render(<Component {...mockProps} />);

    // Assert - Verify the expected outcome
    expect(screen.getByText('Test Title')).toBeInTheDocument();
  });
});

2. Meaningful Test Names

// Good
it('should display error message when API call fails');

// Bad
it('should work');

3. Test User Behavior, Not Implementation

// Good - Test what user sees
expect(screen.getByRole('button')).toBeInTheDocument();

// Bad - Test implementation details
expect(component.state.isVisible).toBe(true);

4. Use Data Test IDs Sparingly

// Only when semantic queries don't work
<button data-testid="submit-button">Submit</button>

5. Mock External Dependencies

// Mock API calls
jest.mock('./api', () => ({
  fetchData: jest.fn(),
}));

// Mock modules
jest.mock('next/router', () => ({
  useRouter: () => ({
    push: jest.fn(),
    pathname: '/test',
  }),
}));

Benefits of Unit Testing

1. Early Bug Detection

  • Catch bugs during development phase
  • Reduce production issues
  • Faster debugging process

2. Code Quality Improvement

  • Forces better code structure
  • Encourages modular design
  • Improves maintainability

3. Documentation

  • Tests serve as living documentation
  • Show expected behavior
  • Help new developers understand code

4. Refactoring Confidence

  • Safe to refactor with test coverage
  • Regression prevention
  • Maintain functionality during changes

5. Development Speed

  • Faster debugging
  • Automated testing
  • Continuous integration support

6. Team Collaboration

  • Shared understanding of requirements
  • Reduced code review time
  • Better code quality standards

Limitations and Considerations

1. Testing Limitations

  • Cannot test visual appearance: Jest can't verify CSS styling or visual design
  • Limited browser testing: No real browser environment
  • Mock limitations: Mocks may not perfectly replicate real behavior
  • Integration gaps: Unit tests don't catch integration issues

2. Performance Considerations

  • Test execution time: Large test suites can be slow
  • Memory usage: Each test runs in isolation
  • CI/CD impact: Tests must complete before deployment

3. Development Time Impact

  • Initial setup time: 15-25% increase in initial development time for test setup and configuration
  • Ongoing development: 20-40% increase in feature development time due to writing tests
  • Learning curve: 2-4 weeks additional time for team members to learn testing best practices
  • Test maintenance: 10-15% additional time for maintaining and updating existing tests
  • Debugging tests: 5-10% additional time spent debugging test failures and flaky tests

4. Maintenance Overhead

  • Test maintenance: Tests need updates when code changes
  • False positives: Tests may fail due to implementation changes
  • Test complexity: Complex tests can be hard to maintain

5. Coverage Limitations

  • 100% coverage doesn't mean bug-free: Edge cases may be missed
  • Integration testing needed: Unit tests don't cover system interactions
  • User experience testing: Cannot test actual user workflows

6. Frontend-Specific Challenges

  • DOM manipulation: Complex DOM interactions are hard to test
  • Async operations: Testing async code requires careful handling
  • State management: Complex state interactions can be difficult to test
  • Third-party libraries: Some libraries are hard to mock effectively

Troubleshooting

Common Issues and Solutions

1. Module Resolution Issues

// Add to jest.config.ts
moduleNameMapper: {
  '^@/(.*)$': '<rootDir>/src/$1',
  '^@shared/(.*)$': '<rootDir>/../../libs/shared-lib/src/$1',
}

2. CSS Import Issues

// Add to jest.config.ts
moduleNameMapper: {
  '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
}

3. Environment Variables

// Add to jest.setup.ts
process.env.NODE_ENV = 'test';
process.env.API_URL = 'http://localhost:3000';

4. Async Testing

// Use async/await
it('should handle async operations', async () => {
  const { result } = renderHook(() => useAsyncData());

  await waitFor(() => {
    expect(result.current.data).toBeDefined();
  });
});

5. Memory Leaks

// Clean up after tests
afterEach(() => {
  cleanup();
  jest.clearAllMocks();
});

Conclusion

Unit testing in NX workspace with Jest provides a robust foundation for frontend application testing. While it has limitations, the benefits significantly outweigh the challenges when implemented correctly. Focus on testing user behavior, maintain good test coverage, and complement unit tests with integration and end-to-end tests for comprehensive quality assurance.

Key Takeaways:

  • Use NX's built-in Jest configuration for optimal performance
  • Focus on testing user behavior rather than implementation details
  • Maintain good test coverage but don't obsess over 100%
  • Complement unit tests with other testing strategies
  • Keep tests simple, readable, and maintainable
  • Use proper mocking and test utilities for complex scenarios

This testing strategy will help ensure your frontend applications are reliable, maintainable, and bug-free while supporting your team's development workflow.