08-testing-debugging-devops.md

Module 08: Testing, Debugging & DevOps Basics

Goal: Learn testing with Jest, debugging techniques, Docker basics, and CI/CD fundamentals. Time: 2 days of focused study Prerequisites: Module 01-04


Table of Contents

  1. Why Testing Matters
  2. Testing with Jest
  3. Unit Testing
  4. Integration Testing (API Testing)
  5. Mocking
  6. Test-Driven Development (TDD)
  7. Debugging Techniques
  8. Docker Basics
  9. CI/CD Basics
  10. Interview Questions

1. Why Testing Matters

Without tests: "It works on my machine!" → Deploy → Production crashes → 3 AM bug fix With tests: Write code → Tests catch bugs → Fix before deploy → Sleep peacefully Testing Pyramid: / \ / E2E \ ← Few, slow, expensive (Puppeteer, Playwright) /────────\ /Integration\ ← Some, medium speed (API tests, DB tests) /──────────────\ / Unit Tests \ ← Many, fast, cheap (functions, classes) /──────────────────\

2. Testing with Jest

npm install --save-dev jest
// package.json { "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" } }

Basic Test Structure

// math.js function add(a, b) { return a + b; } function multiply(a, b) { return a * b; } function divide(a, b) { if (b === 0) throw new Error('Cannot divide by zero'); return a / b; } module.exports = { add, multiply, divide }; // math.test.js (Jest finds files matching *.test.js or *.spec.js) const { add, multiply, divide } = require('./math'); describe('Math functions', () => { // describe groups related tests describe('add', () => { test('adds two positive numbers', () => { expect(add(2, 3)).toBe(5); }); test('adds negative numbers', () => { expect(add(-1, -2)).toBe(-3); }); test('adds zero', () => { expect(add(5, 0)).toBe(5); }); }); describe('divide', () => { test('divides two numbers', () => { expect(divide(10, 2)).toBe(5); }); test('throws on division by zero', () => { expect(() => divide(10, 0)).toThrow('Cannot divide by zero'); }); }); });

Common Matchers

// Equality expect(value).toBe(5); // Strict equality (===) expect(obj).toEqual({ a: 1 }); // Deep equality (for objects/arrays) expect(val).toStrictEqual(obj); // Deep equality + checks undefined properties // Truthiness expect(value).toBeTruthy(); expect(value).toBeFalsy(); expect(value).toBeNull(); expect(value).toBeUndefined(); expect(value).toBeDefined(); // Numbers expect(value).toBeGreaterThan(3); expect(value).toBeGreaterThanOrEqual(3); expect(value).toBeLessThan(5); expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point comparison // Strings expect(string).toMatch(/regex/); expect(string).toContain('substring'); // Arrays expect(array).toContain(item); expect(array).toHaveLength(3); expect(array).toEqual(expect.arrayContaining([1, 2])); // Objects expect(obj).toHaveProperty('key'); expect(obj).toHaveProperty('key', 'value'); expect(obj).toMatchObject({ name: 'Alice' }); // Partial match // Exceptions expect(() => func()).toThrow(); expect(() => func()).toThrow('error message'); expect(() => func()).toThrow(TypeError); // Async await expect(asyncFunc()).resolves.toBe('value'); await expect(asyncFunc()).rejects.toThrow('error'); // Negation expect(value).not.toBe(5); expect(array).not.toContain(item);

Setup and Teardown

describe('Database tests', () => { // Runs once before all tests in this describe beforeAll(async () => { await db.connect(); }); // Runs once after all tests afterAll(async () => { await db.disconnect(); }); // Runs before EACH test beforeEach(async () => { await db.clear(); // Clean slate for each test }); // Runs after EACH test afterEach(() => { jest.restoreAllMocks(); }); test('creates a user', async () => { const user = await createUser({ name: 'Alice' }); expect(user.name).toBe('Alice'); }); });

3. Unit Testing

Testing a Service

// user.service.js class UserService { constructor(userRepository) { this.repo = userRepository; } async getUser(id) { const user = await this.repo.findById(id); if (!user) throw new NotFoundError('User'); return user; } async createUser(data) { const existing = await this.repo.findByEmail(data.email); if (existing) throw new ConflictError('Email already exists'); data.password = await bcrypt.hash(data.password, 12); return this.repo.create(data); } } // user.service.test.js const UserService = require('./user.service'); describe('UserService', () => { let service; let mockRepo; beforeEach(() => { // Create mock repository mockRepo = { findById: jest.fn(), findByEmail: jest.fn(), create: jest.fn(), }; service = new UserService(mockRepo); }); describe('getUser', () => { test('returns user when found', async () => { const mockUser = { id: '1', name: 'Alice' }; mockRepo.findById.mockResolvedValue(mockUser); const user = await service.getUser('1'); expect(user).toEqual(mockUser); expect(mockRepo.findById).toHaveBeenCalledWith('1'); expect(mockRepo.findById).toHaveBeenCalledTimes(1); }); test('throws NotFoundError when user not found', async () => { mockRepo.findById.mockResolvedValue(null); await expect(service.getUser('999')).rejects.toThrow('User'); }); }); describe('createUser', () => { test('creates user with hashed password', async () => { mockRepo.findByEmail.mockResolvedValue(null); mockRepo.create.mockImplementation((data) => ({ id: '1', ...data })); const user = await service.createUser({ name: 'Alice', email: 'alice@test.com', password: 'password123', }); expect(user.name).toBe('Alice'); expect(user.password).not.toBe('password123'); // Password was hashed expect(mockRepo.create).toHaveBeenCalledTimes(1); }); test('throws ConflictError if email exists', async () => { mockRepo.findByEmail.mockResolvedValue({ id: '1' }); await expect(service.createUser({ email: 'existing@test.com', password: 'pass', })).rejects.toThrow('Email already exists'); expect(mockRepo.create).not.toHaveBeenCalled(); }); }); });

4. Integration Testing

Testing Express API Endpoints

// npm install --save-dev supertest const request = require('supertest'); const app = require('./app'); // Your Express app (don't call .listen()) describe('User API', () => { describe('POST /api/users', () => { test('creates a user with valid data', async () => { const response = await request(app) .post('/api/users') .send({ name: 'Alice', email: 'alice@test.com', password: 'Password123!', }) .expect(201) .expect('Content-Type', /json/); expect(response.body.status).toBe('success'); expect(response.body.data).toHaveProperty('id'); expect(response.body.data.name).toBe('Alice'); expect(response.body.data).not.toHaveProperty('password'); }); test('returns 400 for invalid email', async () => { const response = await request(app) .post('/api/users') .send({ name: 'Alice', email: 'not-an-email', password: 'Pass123!' }) .expect(400); expect(response.body.status).toBe('error'); expect(response.body.errors).toBeDefined(); }); test('returns 409 for duplicate email', async () => { // First create await request(app).post('/api/users').send({ name: 'Alice', email: 'alice@test.com', password: 'Pass123!', }); // Duplicate const response = await request(app) .post('/api/users') .send({ name: 'Bob', email: 'alice@test.com', password: 'Pass123!' }) .expect(409); }); }); describe('GET /api/users/:id', () => { test('requires authentication', async () => { await request(app) .get('/api/users/1') .expect(401); }); test('returns user when authenticated', async () => { const token = generateTestToken({ userId: '1', role: 'admin' }); const response = await request(app) .get('/api/users/1') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.data).toHaveProperty('name'); }); }); });

5. Mocking

// ---- MOCK FUNCTIONS ---- const mockFn = jest.fn(); mockFn('hello'); expect(mockFn).toHaveBeenCalledWith('hello'); expect(mockFn).toHaveBeenCalledTimes(1); // Mock return values const mockFn = jest.fn() .mockReturnValue(42) // Always returns 42 .mockReturnValueOnce(1) // First call returns 1 .mockReturnValueOnce(2) // Second call returns 2 .mockResolvedValue({ data: [] }) // Returns resolved promise .mockRejectedValue(new Error()) // Returns rejected promise .mockImplementation((x) => x * 2); // Custom implementation // ---- MOCK MODULES ---- // Mock entire module jest.mock('./database'); const db = require('./database'); db.query.mockResolvedValue([{ id: 1, name: 'Alice' }]); // Mock specific functions jest.mock('axios', () => ({ get: jest.fn().mockResolvedValue({ data: { users: [] } }), post: jest.fn().mockResolvedValue({ data: { id: 1 } }), })); // ---- SPY ON EXISTING METHODS ---- const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); myFunction(); // calls console.log internally expect(consoleSpy).toHaveBeenCalledWith('expected message'); consoleSpy.mockRestore(); // ---- MOCK TIMERS ---- jest.useFakeTimers(); const callback = jest.fn(); setTimeout(callback, 1000); expect(callback).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); expect(callback).toHaveBeenCalledTimes(1); jest.useRealTimers();

6. TDD

Test-Driven Development cycle: 1. RED: Write a failing test 2. GREEN: Write minimal code to make it pass 3. REFACTOR: Improve code without changing behavior Repeat!
// Example: TDD a password validator // Step 1: RED — Write test first describe('validatePassword', () => { test('rejects passwords shorter than 8 characters', () => { expect(validatePassword('short')).toEqual({ valid: false, errors: expect.arrayContaining(['Password must be at least 8 characters']), }); }); }); // Step 2: GREEN — Write minimal code function validatePassword(password) { const errors = []; if (password.length < 8) errors.push('Password must be at least 8 characters'); return { valid: errors.length === 0, errors }; } // Step 3: Write more tests, iterate test('requires uppercase letter', () => { expect(validatePassword('lowercase1')).toEqual({ valid: false, errors: expect.arrayContaining(['Password must contain an uppercase letter']), }); }); // Update code to pass...

7. Debugging Techniques

// 1. Console methods console.log(value); // Basic output console.dir(obj, { depth: null }); // Deep inspect console.table([{a:1}, {a:2}]); // Table format console.time('label'); // Start timer console.timeEnd('label'); // End timer — shows duration console.trace(); // Print stack trace console.group('Section'); // Group related logs console.groupEnd(); // 2. Node.js debugger // Add 'debugger;' statement in code, run with: // node --inspect-brk app.js // Open chrome://inspect in Chrome // 3. Debug module // npm install debug const debug = require('debug'); const dbDebug = debug('app:db'); const authDebug = debug('app:auth'); dbDebug('Connected to %s', dbUrl); authDebug('User %s logged in', userId); // Run with: DEBUG=app:* node app.js // Or: DEBUG=app:db node app.js (only db logs) // 4. Error stack traces try { riskyOperation(); } catch (err) { console.error('Full error:', err); console.error('Message:', err.message); console.error('Stack:', err.stack); console.error('Name:', err.name); }

8. Docker Basics

Why Docker?

Problem: "Works on my machine" — different Node versions, OS, dependencies Solution: Docker packages your app + its environment into a container Container = lightweight, isolated environment that runs your app Image = blueprint for creating containers (like a class) Container = running instance of an image (like an object)

Dockerfile

# Dockerfile FROM node:20-alpine # Set working directory WORKDIR /app # Copy package files first (for caching) COPY package*.json ./ # Install dependencies RUN npm ci --only=production # Copy source code COPY . . # Expose port EXPOSE 3000 # Health check HEALTHCHECK --interval=30s --timeout=3s \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # Start the app CMD ["node", "src/index.js"]

Docker Compose (Multi-Container)

# docker-compose.yml version: '3.8' services: app: build: . ports: - "3000:3000" environment: - NODE_ENV=production - DB_URL=mongodb://mongo:27017/mydb - REDIS_URL=redis://redis:6379 depends_on: - mongo - redis mongo: image: mongo:7 ports: - "27017:27017" volumes: - mongo-data:/data/db redis: image: redis:7-alpine ports: - "6379:6379" volumes: mongo-data:
# Essential Docker commands docker build -t my-app . # Build image docker run -p 3000:3000 my-app # Run container docker compose up -d # Start all services docker compose down # Stop all services docker compose logs -f app # View logs docker ps # List running containers docker exec -it <container> sh # Shell into container

9. CI/CD Basics

GitHub Actions Example

# .github/workflows/ci.yml name: CI/CD on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest services: redis: image: redis:7 ports: [6379:6379] mongo: image: mongo:7 ports: [27017:27017] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci - run: npm test - run: npm run lint deploy: needs: test if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: echo "Deploy to production"

10. Interview Questions

Q1: What's the difference between unit tests and integration tests?

Answer: Unit tests test individual functions/classes in isolation (with mocked dependencies). They're fast, focused, and numerous. Integration tests test how components work together (real database, real API calls). They're slower but catch issues unit tests miss. A good ratio is many unit tests, some integration tests, few E2E tests (testing pyramid).

Q2: How do you test async code in Jest?

Answer: Return a promise from the test function, or use async/await. For resolved promises: await expect(fn()).resolves.toBe(value). For rejected: await expect(fn()).rejects.toThrow(). Always use expect.assertions(N) in async tests to ensure assertions actually run.

Q3: What is mocking? Why is it useful?

Answer: Mocking replaces real dependencies (database, API, filesystem) with controlled fake implementations. Benefits: tests run fast (no real DB), tests are isolated (failures are localized), you can simulate edge cases (network errors, timeouts). Use jest.fn() for functions, jest.mock() for modules, jest.spyOn() for existing methods.

Q4: What is Docker and why use it?

Answer: Docker packages applications with their dependencies into lightweight, portable containers. Benefits: consistent environments (no "works on my machine"), easy deployment, isolation between services, reproducible builds. A Dockerfile defines how to build an image; docker-compose.yml defines multi-container setups.


Next Module: 09 - Real-World & Interview Prep — Full project walkthroughs and 200+ interview questions.