Testing Guide

This document covers testing strategies, tools, and best practices for the Wallet Service.

Overview

The project uses a multi-layer testing approach:

Test Type Location Purpose
Unit Tests src/**/*.spec.ts Test individual functions and classes
E2E Tests test/**/*.spec.ts Test API endpoints end-to-end

Running Tests

All Tests

npm run test

Watch Mode

npm run test -- --watch

With Coverage

npm run test -- --coverage

Specific Test File

npm run test -- src/lib/services/example.spec.ts

Writing Unit Tests

Basic Test Structure

// example.spec.ts
import test from 'ava';
import { ExampleService } from './example.service';

test('should return expected value', async t => {
  const service = new ExampleService();
  const result = await service.getValue();
  
  t.is(result, 'expected');
});

test('should throw on invalid input', async t => {
  const service = new ExampleService();
  
  await t.throwsAsync(
    async () => service.process(null),
    { message: 'Invalid input' }
  );
});

Using Setup Functions

For reusable test setup, use helper functions instead of hooks when you need flexibility:

import test from 'ava';

// Setup function with customizable options
function createTestContext(options = {}) {
  return {
    service: new ExampleService(options),
    mockData: { id: '123', name: 'Test' }
  };
}

test('default configuration', async t => {
  const ctx = createTestContext();
  const result = await ctx.service.process(ctx.mockData);
  
  t.truthy(result);
});

test('custom configuration', async t => {
  const ctx = createTestContext({ debug: true });
  const result = await ctx.service.process(ctx.mockData);
  
  t.truthy(result.debugInfo);
});

Using beforeEach Hook

For consistent setup across all tests:

import test from 'ava';

test.beforeEach(t => {
  t.context = {
    service: new ExampleService(),
    testData: generateTestData()
  };
});

test('first test', async t => {
  const result = await t.context.service.process(t.context.testData);
  t.truthy(result);
});

test('second test', async t => {
  const result = await t.context.service.validate(t.context.testData);
  t.true(result.isValid);
});

Comparing Approaches

beforeEach() Setup Functions
Runs for all tests Called only when needed
Same setup for every test Customizable per test
Automatic cleanup with afterEach Manual cleanup required
Better for consistent fixtures Better for varied scenarios

Testing Controllers

Mocking Fastify Request/Reply

import test from 'ava';
import { FastifyRequest, FastifyReply } from 'fastify';
import PassesController from './passes.controller';

function createMockRequest(overrides = {}): Partial<FastifyRequest> {
  return {
    params: {},
    query: {},
    headers: {},
    body: {},
    ...overrides
  };
}

function createMockReply(): Partial<FastifyReply> {
  const reply = {
    statusCode: 200,
    status: function(code: number) {
      this.statusCode = code;
      return this;
    },
    send: function(payload: unknown) {
      return { statusCode: this.statusCode, payload };
    }
  };
  return reply;
}

test('returns 401 without valid key', async t => {
  const controller = new PassesController();
  const request = createMockRequest({
    query: { salesforceId: '123', type: 'apple' }
  });
  const reply = createMockReply();

  await controller.send(request as FastifyRequest, reply as FastifyReply);
  
  t.is(reply.statusCode, 401);
});

Testing Services

Mocking Dependencies

import test from 'ava';
import { DigitalMembershipService } from './digital-membership.service';

// Mock the modules
const mockGetMemberData = async (id: string) => ({
  salesforceId: id,
  name: 'Test Member',
  memberships: [{ type: 'Premium' }]
});

test.beforeEach(t => {
  // Replace module functions with mocks
  t.context.service = new DigitalMembershipService();
  // Setup mocks as needed
});

test('retrieves passes by salesforce ID', async t => {
  const result = await t.context.service.retrieveApplePassesBySalesforceId('123');
  
  t.truthy(result.items);
  t.is(result.count, result.items.length);
});

E2E Testing

Testing API Endpoints

// test/api.spec.ts
import test from 'ava';
import { App } from '../src/app';

let app: App;

test.before(async () => {
  app = App.getInstance();
  await app.listen();
});

test.after(async () => {
  await app.close();
});

test('GET / returns 200', async t => {
  const response = await fetch('http://localhost:3000/');
  
  t.is(response.status, 200);
});

test('GET /passes/update requires serial number', async t => {
  const response = await fetch('http://localhost:3000/passes/update/');
  
  t.is(response.status, 404);
});

Test Coverage

Generating Coverage Reports

npm run test -- --coverage

Coverage reports are generated in the coverage/ directory.

Coverage Targets

Metric Target
Statements > 80%
Branches > 75%
Functions > 80%
Lines > 80%

Mocking External Services

Salesforce Mock

import test from 'ava';

// Create a mock Salesforce module
const mockSalesforce = {
  getMemberData: async (id: string) => ({
    salesforceId: id,
    firstName: 'John',
    lastName: 'Doe',
    email: 'john@example.com',
    memberships: []
  }),
  getSalesforceIdBySerialNumber: async (serial: string) => '001ABC123'
};

test('uses member data from Salesforce', async t => {
  const data = await mockSalesforce.getMemberData('001ABC123');
  
  t.is(data.firstName, 'John');
  t.is(data.email, 'john@example.com');
});

Apple PassKit Mock

const mockAppleModule = {
  createApplePass: async (memberData: any) => {
    // Return a mock Buffer representing a .pkpass file
    return Buffer.from('mock-pkpass-content');
  }
};

Testing Best Practices

Do's

Test behavior, not implementation

// Good: Tests the outcome
test('returns member passes', async t => {
  const result = await service.getPassesForMember('123');
  t.is(result.count, 2);
});

Use descriptive test names

test('returns 401 when authorization header is missing', async t => {
  // ...
});

Keep tests independent

test.beforeEach(t => {
  // Fresh context for each test
  t.context.service = new Service();
});

Test edge cases

test('handles empty member list', async t => {
  const result = await service.getPassesForMember('no-memberships');
  t.is(result.count, 0);
  t.deepEqual(result.items, []);
});

Don'ts

Don't test external services directly

// Bad: Depends on Salesforce availability
test('fetches from Salesforce', async t => {
  const data = await salesforce.query('SELECT Id FROM Account');
});

Don't share state between tests

// Bad: Tests depend on each other
let sharedData;
test('first creates data', t => { sharedData = {...} });
test('second uses data', t => { /* uses sharedData */ });

Don't use magic numbers/strings

// Bad
t.is(result.code, 200);

// Good
const HTTP_OK = 200;
t.is(result.code, HTTP_OK);

Debugging Failed Tests

Verbose Output

npm run test -- --verbose

Run Single Test

npm run test -- --match="test name pattern"

Debug in VS Code

Add a breakpoint and use the Debug Test launch configuration.


Continuous Integration

Tests run automatically on:

See bitbucket-pipelines.yml or equivalent CI configuration for details.