31 Unit Testing mit Jest und Mocha

Unit Testing bildet das Fundament einer robusten Teststrategie. Jest und Mocha sind die beiden dominierenden Frameworks für Unit Tests in der JavaScript-Entwicklung. Beide bieten umfangreiche Funktionalitäten, unterscheiden sich jedoch in ihrer Philosophie und ihrem Funktionsumfang.

31.1 Jest: Das All-in-One Testing Framework

Jest wurde von Facebook entwickelt und ist heute das meistgenutzte JavaScript-Testing-Framework. Es zeichnet sich durch seine “Zero-Configuration”-Philosophie aus und bringt alle notwendigen Tools für das Testen mit.

31.1.1 Jest Setup und Grundkonfiguration

Die Installation von Jest erfolgt über npm:

npm install --save-dev jest

Die einfachste package.json-Konfiguration:

{
  "scripts": {
    "test": "jest"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}

Jest erkennt automatisch Testdateien mit den Endungen .test.js, .spec.js oder in __tests__-Ordnern.

31.1.2 Grundlegende Jest-Syntax

Jest verwendet eine intuitive, ausdrucksstarke Syntax für Tests:

// 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('Division by zero');
  }
  return a / b;
}

module.exports = { add, multiply, divide };
// math.test.js
const { add, multiply, divide } = require('./math');

describe('Math functions', () => {
  test('should add two numbers correctly', () => {
    expect(add(2, 3)).toBe(5);
    expect(add(-1, 1)).toBe(0);
    expect(add(0, 0)).toBe(0);
  });

  test('should multiply two numbers correctly', () => {
    expect(multiply(3, 4)).toBe(12);
    expect(multiply(-2, 5)).toBe(-10);
  });

  test('should throw error when dividing by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
});

31.1.3 Jest Matchers

Jest bietet eine umfangreiche Sammlung von Matchers für verschiedene Assertions:

describe('Jest Matchers Demo', () => {
  test('equality matchers', () => {
    expect(2 + 2).toBe(4);                    // Exakte Gleichheit
    expect({ name: 'John' }).toEqual({ name: 'John' }); // Objektvergleich
  });

  test('truthiness matchers', () => {
    expect(true).toBeTruthy();
    expect(false).toBeFalsy();
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect('Hello').toBeDefined();
  });

  test('number matchers', () => {
    expect(2 + 2).toBeGreaterThan(3);
    expect(Math.PI).toBeCloseTo(3.14, 2);
  });

  test('string matchers', () => {
    expect('Hello World').toMatch(/World/);
    expect('hello@example.com').toMatch(/^\w+@\w+\.\w+$/);
  });

  test('array matchers', () => {
    expect(['apple', 'banana', 'orange']).toContain('banana');
    expect([1, 2, 3]).toHaveLength(3);
  });
});

31.1.4 Mocking in Jest

Jest bietet eingebaute Mocking-Funktionalitäten ohne externe Abhängigkeiten:

// userService.js
const apiClient = require('./apiClient');

class UserService {
  async getUser(id) {
    const response = await apiClient.get(`/users/${id}`);
    return response.data;
  }

  async createUser(userData) {
    const response = await apiClient.post('/users', userData);
    return response.data;
  }
}

module.exports = UserService;
// userService.test.js
const UserService = require('./userService');
const apiClient = require('./apiClient');

// Mock des gesamten Moduls
jest.mock('./apiClient');

describe('UserService', () => {
  let userService;

  beforeEach(() => {
    userService = new UserService();
    jest.clearAllMocks();
  });

  test('should get user by id', async () => {
    const mockUser = { id: 1, name: 'John Doe' };
    apiClient.get.mockResolvedValue({ data: mockUser });

    const result = await userService.getUser(1);

    expect(apiClient.get).toHaveBeenCalledWith('/users/1');
    expect(result).toEqual(mockUser);
  });

  test('should create new user', async () => {
    const userData = { name: 'Jane Smith', email: 'jane@example.com' };
    const createdUser = { id: 2, ...userData };
    
    apiClient.post.mockResolvedValue({ data: createdUser });

    const result = await userService.createUser(userData);

    expect(apiClient.post).toHaveBeenCalledWith('/users', userData);
    expect(result).toEqual(createdUser);
  });
});

31.1.5 Jest Lifecycle-Hooks

Jest bietet Hooks für Setup und Teardown-Operationen:

describe('Database operations', () => {
  let database;

  beforeAll(async () => {
    // Einmalige Vorbereitung für alle Tests
    database = await connectToDatabase();
  });

  afterAll(async () => {
    // Aufräumen nach allen Tests
    await database.close();
  });

  beforeEach(() => {
    // Vorbereitung vor jedem Test
    database.beginTransaction();
  });

  afterEach(() => {
    // Aufräumen nach jedem Test
    database.rollback();
  });

  test('should save user to database', async () => {
    const user = { name: 'Test User' };
    const savedUser = await database.saveUser(user);
    
    expect(savedUser).toHaveProperty('id');
    expect(savedUser.name).toBe(user.name);
  });
});

31.2 Mocha: Das flexible Testing-Framework

Mocha ist ein etabliertes, flexibles Testing-Framework, das minimalistischer als Jest ist und Entwicklern mehr Kontrolle über ihre Testing-Pipeline gibt.

31.2.1 Mocha Setup und Konfiguration

Mocha benötigt separate Bibliotheken für Assertions und Mocking:

npm install --save-dev mocha chai sinon

Grundlegende package.json-Konfiguration:

{
  "scripts": {
    "test": "mocha"
  },
  "devDependencies": {
    "mocha": "^10.0.0",
    "chai": "^4.3.0",
    "sinon": "^15.0.0"
  }
}

31.2.2 Mocha mit Chai für Assertions

Chai bietet verschiedene Assertion-Stile:

// math.test.js
const { expect } = require('chai');
const { add, multiply, divide } = require('./math');

describe('Math functions', function() {
  it('should add two numbers correctly', function() {
    expect(add(2, 3)).to.equal(5);
    expect(add(-1, 1)).to.equal(0);
  });

  it('should multiply two numbers correctly', function() {
    expect(multiply(3, 4)).to.equal(12);
    expect(multiply(-2, 5)).to.equal(-10);
  });

  it('should throw error when dividing by zero', function() {
    expect(() => divide(10, 0)).to.throw('Division by zero');
  });
});

31.2.3 Chai Assertion-Stile

Chai bietet drei Assertion-Stile für unterschiedliche Präferenzen:

const { expect, should, assert } = require('chai');

describe('Chai assertion styles', function() {
  const user = { name: 'John', age: 30, active: true };

  it('expect style (BDD)', function() {
    expect(user).to.be.an('object');
    expect(user.name).to.equal('John');
    expect(user.age).to.be.above(18);
    expect(user).to.have.property('active', true);
  });

  it('should style (BDD)', function() {
    should.exist(user);
    user.should.be.an('object');
    user.should.have.property('name', 'John');
  });

  it('assert style (TDD)', function() {
    assert.isObject(user);
    assert.equal(user.name, 'John');
    assert.isTrue(user.active);
    assert.property(user, 'age');
  });
});

31.2.4 Mocking mit Sinon

Sinon bietet umfangreiche Mocking-Funktionalitäten für Mocha:

// userService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const UserService = require('./userService');
const apiClient = require('./apiClient');

describe('UserService', function() {
  let userService;
  let apiStub;

  beforeEach(function() {
    userService = new UserService();
    apiStub = sinon.stub(apiClient);
  });

  afterEach(function() {
    sinon.restore();
  });

  it('should get user by id', async function() {
    const mockUser = { id: 1, name: 'John Doe' };
    apiStub.get.resolves({ data: mockUser });

    const result = await userService.getUser(1);

    expect(apiStub.get).to.have.been.calledWith('/users/1');
    expect(result).to.deep.equal(mockUser);
  });

  it('should handle API errors gracefully', async function() {
    apiStub.get.rejects(new Error('Network error'));

    try {
      await userService.getUser(1);
      expect.fail('Should have thrown an error');
    } catch (error) {
      expect(error.message).to.equal('Network error');
    }
  });
});

31.2.5 Sinon Spies, Stubs und Mocks

Sinon unterscheidet zwischen verschiedenen Test-Doubles:

describe('Sinon test doubles', function() {
  it('should use spies to monitor function calls', function() {
    const callback = sinon.spy();
    const processor = new DataProcessor(callback);
    
    processor.process(['data1', 'data2']);
    
    expect(callback).to.have.been.calledTwice;
    expect(callback.firstCall).to.have.been.calledWith('data1');
  });

  it('should use stubs to control function behavior', function() {
    const fileSystem = { readFile: sinon.stub() };
    fileSystem.readFile.withArgs('config.json').returns('{"setting": "value"}');
    fileSystem.readFile.withArgs('missing.json').throws(new Error('File not found'));

    const config = loadConfig('config.json', fileSystem);
    expect(config.setting).to.equal('value');
  });

  it('should use mocks for strict verification', function() {
    const logger = sinon.mock(console);
    logger.expects('log').once().withArgs('Processing complete');
    
    performTask();
    
    logger.verify(); // Fails if expectations not met
  });
});

31.3 Jest vs. Mocha: Vergleich und Entscheidungshilfe

31.3.1 Jest Vorteile

Zero Configuration: Jest funktioniert sofort ohne aufwändige Konfiguration. Es bringt alle notwendigen Tools mit: Test Runner, Assertions, Mocking und Code Coverage.

Snapshot Testing: Jest bietet eingebautes Snapshot Testing für React-Komponenten und andere serialisierbare Objekte.

Parallele Ausführung: Jest führt Tests standardmäßig parallel aus, was die Ausführungszeit reduziert.

Watch Mode: Intelligenter Watch Mode, der nur relevante Tests bei Änderungen ausführt.

// Jest Snapshot Test Beispiel
test('should render user component correctly', () => {
  const user = { name: 'John', email: 'john@example.com' };
  const rendered = renderUser(user);
  
  expect(rendered).toMatchSnapshot();
});

31.3.2 Mocha Vorteile

Flexibilität: Mocha ist modularer und erlaubt die freie Wahl von Assertion-Bibliotheken, Mocking-Tools und anderen Ergänzungen.

Browser-Support: Mocha kann direkt im Browser ausgeführt werden, was für bestimmte Testszenarien wichtig ist.

Vielseitige Reporter: Umfangreiche Auswahl an Report-Formaten und die Möglichkeit, eigene Reporter zu erstellen.

Asynchrone Tests: Mocha bietet verschiedene Ansätze für asynchrone Tests (Callbacks, Promises, async/await).

// Mocha asynchroner Test mit verschiedenen Patterns
describe('Async testing patterns', function() {
  it('using callbacks', function(done) {
    asyncFunction((error, result) => {
      expect(error).to.be.null;
      expect(result).to.equal('success');
      done();
    });
  });

  it('using promises', function() {
    return asyncFunction()
      .then(result => {
        expect(result).to.equal('success');
      });
  });

  it('using async/await', async function() {
    const result = await asyncFunction();
    expect(result).to.equal('success');
  });
});

31.4 Performance und Best Practices

31.4.1 Test-Performance optimieren

// Jest: Parallele Tests und Test-Isolation
describe('Performance optimized tests', () => {
  // Teure Setup-Operationen einmal durchführen
  let expensiveResource;
  
  beforeAll(async () => {
    expensiveResource = await createExpensiveResource();
  });

  // Leichtgewichtige Tests
  test('quick operation test', () => {
    const result = expensiveResource.quickOperation();
    expect(result).toBeDefined();
  });

  // Nur bei Bedarf aufwändige Operationen
  test('expensive operation test', async () => {
    const result = await expensiveResource.expensiveOperation();
    expect(result).toHaveProperty('status', 'completed');
  }, 10000); // Timeout erhöhen für langsame Tests
});

31.4.2 Debugging-Tipps

// Debug-Ausgaben in Tests
describe('Debugging example', () => {
  test('debug failing test', () => {
    const data = processData(inputData);
    
    // Temporäre Debug-Ausgabe
    console.log('Processed data:', JSON.stringify(data, null, 2));
    
    expect(data.status).toBe('success');
  });
});