Episode 3 — NodeJS MongoDB Backend Architecture / 3.18 — Testing Tools

3.18.b — Unit Testing with Jest

Jest is a zero-configuration, batteries-included testing framework by Meta that provides a test runner, assertion library, mocking utilities, and code coverage -- all in a single package, making it the most popular testing tool in the Node.js ecosystem.


<< Previous: 3.18.a — Introduction to Testing | Next: 3.18.c — API Testing >>


1. What is Jest?

Jest is an open-source JavaScript testing framework created and maintained by Meta (formerly Facebook). It was originally designed for testing React applications, but it works equally well for testing any JavaScript code -- including Node.js backends, utility libraries, and Express APIs.

Why Jest?

FeatureDescription
Zero configurationWorks out of the box for most projects. No plugins needed.
Built-in assertionsexpect() API with 50+ matchers -- no separate library required
Built-in mockingjest.fn(), jest.mock(), jest.spyOn() -- no Sinon needed
Built-in coverageRun --coverage flag for a full code coverage report
Watch modeAutomatically re-runs tests when files change
Snapshot testingCapture output and detect unintended changes
Parallel executionRuns test files in parallel worker processes
Rich ecosystemHuge community, extensive documentation, many plugins

2. Installation and Setup

Installing Jest

# Install Jest as a development dependency
npm install -D jest

Configuring package.json

Add a test script to your package.json:

{
  "name": "my-app",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "devDependencies": {
    "jest": "^29.7.0"
  }
}

jest.config.js Options

For more control, create a jest.config.js in your project root:

module.exports = {
  // Where to look for test files
  testMatch: [
    '**/__tests__/**/*.js',
    '**/?(*.)+(spec|test).js'
  ],

  // The test environment (node for backend, jsdom for frontend)
  testEnvironment: 'node',

  // Where to output coverage reports
  coverageDirectory: 'coverage',

  // Which files to collect coverage from
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/**/index.js'
  ],

  // Coverage thresholds (fail if below these)
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },

  // Setup files to run before tests
  setupFilesAfterSetup: ['./tests/setup.js'],

  // Timeout for each test (default: 5000ms)
  testTimeout: 10000,

  // Display individual test results
  verbose: true
};

3. Test File Naming and Organization

Jest automatically finds test files using these conventions:

ConventionExampleDescription
*.test.jsuserService.test.jsTest file next to source file
*.spec.jsuserService.spec.jsAlternate naming convention
__tests__/ folder__tests__/userService.jsTests in a dedicated folder

Common Project Structures

# Option 1: Test files alongside source files (recommended)
src/
  services/
    userService.js
    userService.test.js
  utils/
    helpers.js
    helpers.test.js

# Option 2: Mirror structure in a tests folder
src/
  services/
    userService.js
  utils/
    helpers.js
tests/
  services/
    userService.test.js
  utils/
    helpers.test.js

# Option 3: __tests__ folders inside each directory
src/
  services/
    __tests__/
      userService.test.js
    userService.js

4. Writing Your First Tests

Basic Test Structure

// math.js
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

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

// describe() groups related tests
describe('Math Functions', () => {

  // it() or test() defines a single test case
  it('should add two numbers correctly', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('should handle negative numbers', () => {
    expect(add(-1, -2)).toBe(-3);
  });

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

  test('should return 0 when multiplying by 0', () => {
    expect(multiply(5, 0)).toBe(0);
  });
});

Running Tests

# Run all tests
npm test

# Run tests in watch mode (re-runs on file changes)
npm run test:watch

# Run a specific test file
npx jest math.test.js

# Run tests matching a pattern
npx jest --testPathPattern="service"

# Run tests with verbose output
npx jest --verbose

5. Assertions (Matchers)

Jest provides a rich set of matchers through the expect() API. These are the tools you use to verify your code's behavior.

Equality Matchers

// toBe — strict equality (===), use for primitives
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');
expect(true).toBe(true);

// toEqual — deep equality, use for objects and arrays
expect({ name: 'Alice' }).toEqual({ name: 'Alice' });
expect([1, 2, 3]).toEqual([1, 2, 3]);

// toStrictEqual — like toEqual but also checks:
//   - undefined properties
//   - array sparseness
//   - object types (class instances)
expect({ name: 'Alice', age: undefined }).not.toStrictEqual({ name: 'Alice' });

Truthiness Matchers

expect(true).toBeTruthy();          // truthy value (not false, 0, '', null, undefined, NaN)
expect(false).toBeFalsy();          // falsy value
expect(null).toBeNull();            // === null
expect(undefined).toBeUndefined();  // === undefined
expect('hello').toBeDefined();      // !== undefined

Number Matchers

expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(5).toBeLessThanOrEqual(5);

// For floating point — avoid toBe due to precision issues
expect(0.1 + 0.2).toBeCloseTo(0.3);  // Passes (accounts for floating-point errors)
expect(0.1 + 0.2).not.toBe(0.3);     // Would fail! (0.30000000000000004)

String Matchers

// toMatch — regex or substring
expect('Hello, World!').toMatch(/World/);
expect('Hello, World!').toMatch('World');
expect('user@email.com').toMatch(/^[\w.-]+@[\w.-]+\.\w+$/);

Array and Iterable Matchers

const fruits = ['apple', 'banana', 'cherry'];

expect(fruits).toContain('banana');           // Array includes item (===)
expect(fruits).toHaveLength(3);               // Array length

const users = [{ name: 'Alice' }, { name: 'Bob' }];
expect(users).toContainEqual({ name: 'Alice' }); // Deep equality check in array

Object Matchers

const user = { name: 'Alice', age: 30, email: 'alice@test.com' };

expect(user).toHaveProperty('name');                    // Property exists
expect(user).toHaveProperty('name', 'Alice');           // Property has specific value
expect(user).toHaveProperty('age', expect.any(Number)); // Property is any number

// toMatchObject — partial match (object contains at least these properties)
expect(user).toMatchObject({ name: 'Alice', age: 30 });
// Passes even though user also has 'email'

Exception Matchers

function throwError() {
  throw new Error('Something went wrong');
}

function throwCustom() {
  throw new TypeError('Invalid type');
}

// Must wrap the call in a function
expect(() => throwError()).toThrow();
expect(() => throwError()).toThrow('Something went wrong');
expect(() => throwError()).toThrow(/wrong/);
expect(() => throwCustom()).toThrow(TypeError);

The .not Modifier

Any matcher can be negated with .not:

expect(5).not.toBe(10);
expect({ a: 1 }).not.toEqual({ a: 2 });
expect([1, 2, 3]).not.toContain(4);
expect('hello').not.toMatch(/goodbye/);

Quick Reference Table

CategoryMatcherPurpose
EqualitytoBe(value)Strict equality (===)
EqualitytoEqual(value)Deep equality
EqualitytoStrictEqual(value)Strict deep equality
TruthinesstoBeTruthy()Truthy value
TruthinesstoBeFalsy()Falsy value
TruthinesstoBeNull()Is null
TruthinesstoBeUndefined()Is undefined
TruthinesstoBeDefined()Is not undefined
NumberstoBeGreaterThan(n)>
NumberstoBeLessThan(n)<
NumberstoBeCloseTo(n)Floating-point safe ===
StringstoMatch(regex/string)Regex or substring match
ArraystoContain(item)Array includes (===)
ArraystoContainEqual(item)Array includes (deep equal)
ArraystoHaveLength(n)Array/string length
ObjectstoHaveProperty(key, val?)Object property check
ObjectstoMatchObject(subset)Partial object match
ExceptionstoThrow(error?)Function throws

6. Setup and Teardown

When multiple tests share setup logic, use lifecycle hooks to avoid repetition.

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

  // Runs ONCE before all tests in this describe block
  beforeAll(async () => {
    db = await connectToTestDatabase();
  });

  // Runs ONCE after all tests in this describe block
  afterAll(async () => {
    await db.close();
  });

  // Runs before EACH test
  beforeEach(async () => {
    userService = new UserService(db);
    await db.collection('users').insertMany([
      { name: 'Alice', email: 'alice@test.com' },
      { name: 'Bob', email: 'bob@test.com' }
    ]);
  });

  // Runs after EACH test
  afterEach(async () => {
    await db.collection('users').deleteMany({});
  });

  test('finds all users', async () => {
    const users = await userService.findAll();
    expect(users).toHaveLength(2);
  });

  test('finds user by email', async () => {
    const user = await userService.findByEmail('alice@test.com');
    expect(user.name).toBe('Alice');
  });
});

Execution Order

beforeAll()
  beforeEach()
    test 1
  afterEach()
  beforeEach()
    test 2
  afterEach()
afterAll()

Nested Describe Blocks

describe('UserService', () => {
  beforeEach(() => { /* runs before every test */ });

  describe('findAll', () => {
    beforeEach(() => { /* runs before findAll tests only */ });

    test('returns all users', () => { /* ... */ });
    test('returns empty array when no users', () => { /* ... */ });
  });

  describe('create', () => {
    beforeEach(() => { /* runs before create tests only */ });

    test('creates a user with valid data', () => { /* ... */ });
    test('throws on duplicate email', () => { /* ... */ });
  });
});

7. Testing Async Code

Most Node.js code is asynchronous. Jest supports multiple patterns for testing async functions.

async/await (Recommended)

test('fetches user from database', async () => {
  const user = await userService.findById('123');
  expect(user.name).toBe('Alice');
});

test('throws when user not found', async () => {
  await expect(userService.findById('nonexistent'))
    .rejects.toThrow('User not found');
});

.resolves and .rejects

test('resolves with user data', () => {
  return expect(userService.findById('123'))
    .resolves.toEqual({ name: 'Alice', email: 'alice@test.com' });
});

test('rejects when user not found', () => {
  return expect(userService.findById('nonexistent'))
    .rejects.toThrow('User not found');
});

Callback Style (Legacy)

test('reads file content', (done) => {
  fs.readFile('/path/to/file', 'utf8', (err, data) => {
    expect(err).toBeNull();
    expect(data).toContain('expected content');
    done(); // Signal that the async test is complete
  });
});

8. Mocking

Mocking is one of Jest's most powerful features. It allows you to replace real dependencies with controlled fakes, isolating the code under test.

jest.fn() -- Mock Functions

Create a standalone mock function with call tracking:

const mockCallback = jest.fn();

// Call the mock
mockCallback('hello');
mockCallback('world');

// Verify calls
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith('hello');
expect(mockCallback).toHaveBeenLastCalledWith('world');

// Access call details
expect(mockCallback.mock.calls).toEqual([['hello'], ['world']]);

Mock Return Values

const mockFn = jest.fn();

// Return a specific value
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);

// Return different values on successive calls
mockFn
  .mockReturnValueOnce(1)
  .mockReturnValueOnce(2)
  .mockReturnValue(99);

expect(mockFn()).toBe(1);   // First call
expect(mockFn()).toBe(2);   // Second call
expect(mockFn()).toBe(99);  // All subsequent calls

// Mock resolved/rejected values for async functions
const asyncMock = jest.fn();
asyncMock.mockResolvedValue({ id: 1, name: 'Alice' });
const result = await asyncMock();
expect(result).toEqual({ id: 1, name: 'Alice' });

asyncMock.mockRejectedValue(new Error('DB Error'));
await expect(asyncMock()).rejects.toThrow('DB Error');

jest.mock() -- Auto-Mocking Modules

Replace an entire module with a mock:

// userService.js
const db = require('./database');

async function getUser(id) {
  const user = await db.findById('users', id);
  if (!user) throw new Error('User not found');
  return user;
}

module.exports = { getUser };
// userService.test.js
const db = require('./database');
const { getUser } = require('./userService');

// Auto-mock the entire database module
jest.mock('./database');

describe('getUser', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  test('returns user when found', async () => {
    // Configure the mock's return value
    db.findById.mockResolvedValue({ id: '123', name: 'Alice' });

    const user = await getUser('123');

    expect(user).toEqual({ id: '123', name: 'Alice' });
    expect(db.findById).toHaveBeenCalledWith('users', '123');
  });

  test('throws when user not found', async () => {
    db.findById.mockResolvedValue(null);

    await expect(getUser('999')).rejects.toThrow('User not found');
  });
});

jest.mock() with Custom Implementation

jest.mock('./emailService', () => ({
  sendWelcomeEmail: jest.fn().mockResolvedValue({ sent: true }),
  sendResetEmail: jest.fn().mockResolvedValue({ sent: true })
}));

jest.spyOn() -- Spy on Existing Methods

Spy on a method without replacing it (or replace it temporarily):

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

test('logs when user is created', () => {
  // Spy on console.log without changing its behavior
  const logSpy = jest.spyOn(console, 'log');

  userService.create({ name: 'Alice' });

  expect(logSpy).toHaveBeenCalledWith('User created: Alice');

  // Restore the original implementation
  logSpy.mockRestore();
});

test('spy and override return value', () => {
  const spy = jest.spyOn(userService, 'findAll');
  spy.mockReturnValue([{ name: 'Mock User' }]);

  const users = userService.findAll();
  expect(users).toEqual([{ name: 'Mock User' }]);
  expect(spy).toHaveBeenCalled();

  spy.mockRestore(); // Restore original
});

Clearing, Resetting, and Restoring Mocks

MethodWhat It DoesWhen to Use
mockFn.mockClear()Clears call history and instancesBetween tests -- reset tracking only
mockFn.mockReset()Clears + removes return values and implementationsBetween tests -- full reset
mockFn.mockRestore()Restores original implementation (spyOn only)After spy tests -- undo the spy
jest.clearAllMocks()Calls mockClear on all mocksIn afterEach -- common pattern
jest.resetAllMocks()Calls mockReset on all mocksIn afterEach -- stricter cleanup
jest.restoreAllMocks()Calls mockRestore on all mocksIn afterEach -- restore all spies
afterEach(() => {
  jest.clearAllMocks(); // Most common cleanup pattern
});

9. Test Coverage

Code coverage measures how much of your source code is executed during tests.

Generating a Coverage Report

npx jest --coverage

Reading the Report

--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files           |   87.5  |   75.0   |   90.0  |   88.2  |
 userService.js     |   92.3  |   83.3   |  100.0  |   92.3  |
 helpers.js         |   80.0  |   60.0   |   75.0  |   81.8  |
 validators.js      |  100.0  |  100.0   |  100.0  |  100.0  |
--------------------|---------|----------|---------|---------|

What Each Metric Means

MetricMeasuresExample
StatementsPercentage of executable statements runconst x = 5; is one statement
BranchesPercentage of if/else, switch, ternary paths takenAn if/else has 2 branches
FunctionsPercentage of declared functions that were calledAn unused helper function reduces this
LinesPercentage of lines of code executedSimilar to statements but line-based

Coverage Thresholds

Enforce minimum coverage in jest.config.js:

module.exports = {
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    },
    // Per-file thresholds
    './src/services/': {
      branches: 90,
      functions: 90
    }
  }
};

Coverage Guidelines

Coverage LevelInterpretation
90-100%Excellent, but be careful of testing trivial code
80-90%Very good -- practical target for most projects
60-80%Acceptable for established codebases adding tests retroactively
Below 60%Significant gaps in test coverage

Warning: 100% coverage does not mean zero bugs. Coverage tells you which code was executed, not whether the assertions are meaningful. A test with no assertions gets 100% coverage but verifies nothing.


10. Focusing and Skipping Tests

During development, you often need to run only specific tests or skip broken ones temporarily.

// Run ONLY this test (all others are skipped)
it.only('should handle the specific case I am debugging', () => {
  expect(calculate(42)).toBe(84);
});

// Skip this test (it will show as "skipped" in output)
it.skip('should handle edge case (TODO: fix later)', () => {
  expect(brokenFunction()).toBe(true);
});

// Skip an entire describe block
describe.skip('Legacy Module (not yet migrated)', () => {
  test('old test 1', () => { /* ... */ });
  test('old test 2', () => { /* ... */ });
});

// Run only this describe block
describe.only('Module I am currently working on', () => {
  test('new feature', () => { /* ... */ });
});

Important: Never commit it.only or describe.only to your repository. It will silently skip all other tests in CI. Use a lint rule or pre-commit hook to catch this.


11. Real Example: Testing Utility Functions

// utils/validators.js
function isValidEmail(email) {
  if (typeof email !== 'string') return false;
  const regex = /^[\w.-]+@[\w.-]+\.\w{2,}$/;
  return regex.test(email);
}

function sanitizeInput(input) {
  if (typeof input !== 'string') return '';
  return input.trim().replace(/<[^>]*>/g, '');
}

function generateSlug(title) {
  if (typeof title !== 'string') throw new TypeError('Title must be a string');
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}

module.exports = { isValidEmail, sanitizeInput, generateSlug };
// utils/validators.test.js
const { isValidEmail, sanitizeInput, generateSlug } = require('./validators');

describe('isValidEmail', () => {
  test('returns true for valid emails', () => {
    expect(isValidEmail('user@example.com')).toBe(true);
    expect(isValidEmail('name.surname@domain.co.uk')).toBe(true);
    expect(isValidEmail('user123@test.org')).toBe(true);
  });

  test('returns false for invalid emails', () => {
    expect(isValidEmail('invalid')).toBe(false);
    expect(isValidEmail('user@')).toBe(false);
    expect(isValidEmail('@domain.com')).toBe(false);
    expect(isValidEmail('')).toBe(false);
  });

  test('returns false for non-string input', () => {
    expect(isValidEmail(null)).toBe(false);
    expect(isValidEmail(undefined)).toBe(false);
    expect(isValidEmail(123)).toBe(false);
  });
});

describe('sanitizeInput', () => {
  test('removes HTML tags', () => {
    expect(sanitizeInput('<script>alert("xss")</script>')).toBe('alert("xss")');
    expect(sanitizeInput('Hello <b>World</b>')).toBe('Hello World');
  });

  test('trims whitespace', () => {
    expect(sanitizeInput('  hello  ')).toBe('hello');
  });

  test('returns empty string for non-string input', () => {
    expect(sanitizeInput(null)).toBe('');
    expect(sanitizeInput(42)).toBe('');
  });
});

describe('generateSlug', () => {
  test('converts title to URL-friendly slug', () => {
    expect(generateSlug('Hello World')).toBe('hello-world');
    expect(generateSlug('My Blog Post!')).toBe('my-blog-post');
    expect(generateSlug('  Spaces  Everywhere  ')).toBe('spaces-everywhere');
  });

  test('handles special characters', () => {
    expect(generateSlug('Node.js & Express')).toBe('node-js-express');
    expect(generateSlug('100% Complete!')).toBe('100-complete');
  });

  test('throws TypeError for non-string input', () => {
    expect(() => generateSlug(null)).toThrow(TypeError);
    expect(() => generateSlug(123)).toThrow('Title must be a string');
  });
});

12. Real Example: Testing a Service Layer with Mocked Database

// services/orderService.js
const Order = require('../models/Order');
const emailService = require('./emailService');

async function createOrder(userId, items) {
  if (!items || items.length === 0) {
    throw new Error('Order must contain at least one item');
  }

  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  const order = await Order.create({
    userId,
    items,
    total,
    status: 'pending'
  });

  await emailService.sendOrderConfirmation(userId, order._id, total);

  return order;
}

async function cancelOrder(orderId) {
  const order = await Order.findById(orderId);
  if (!order) throw new Error('Order not found');
  if (order.status === 'shipped') throw new Error('Cannot cancel shipped order');

  order.status = 'cancelled';
  await order.save();

  return order;
}

module.exports = { createOrder, cancelOrder };
// services/orderService.test.js
const { createOrder, cancelOrder } = require('./orderService');
const Order = require('../models/Order');
const emailService = require('./emailService');

// Mock both dependencies
jest.mock('../models/Order');
jest.mock('./emailService');

describe('OrderService', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('createOrder', () => {
    const mockItems = [
      { name: 'Widget', price: 10, quantity: 2 },
      { name: 'Gadget', price: 25, quantity: 1 }
    ];

    test('creates order with correct total', async () => {
      const mockOrder = { _id: 'order123', userId: 'user1', items: mockItems, total: 45, status: 'pending' };
      Order.create.mockResolvedValue(mockOrder);
      emailService.sendOrderConfirmation.mockResolvedValue(true);

      const order = await createOrder('user1', mockItems);

      expect(order.total).toBe(45);
      expect(Order.create).toHaveBeenCalledWith({
        userId: 'user1',
        items: mockItems,
        total: 45,
        status: 'pending'
      });
    });

    test('sends confirmation email after creating order', async () => {
      Order.create.mockResolvedValue({ _id: 'order123', total: 45 });
      emailService.sendOrderConfirmation.mockResolvedValue(true);

      await createOrder('user1', mockItems);

      expect(emailService.sendOrderConfirmation).toHaveBeenCalledWith('user1', 'order123', 45);
    });

    test('throws error for empty items array', async () => {
      await expect(createOrder('user1', [])).rejects.toThrow('Order must contain at least one item');
      expect(Order.create).not.toHaveBeenCalled();
    });

    test('throws error for null items', async () => {
      await expect(createOrder('user1', null)).rejects.toThrow('Order must contain at least one item');
    });
  });

  describe('cancelOrder', () => {
    test('cancels a pending order', async () => {
      const mockOrder = { _id: 'order123', status: 'pending', save: jest.fn().mockResolvedValue(true) };
      Order.findById.mockResolvedValue(mockOrder);

      const result = await cancelOrder('order123');

      expect(result.status).toBe('cancelled');
      expect(mockOrder.save).toHaveBeenCalled();
    });

    test('throws error when order not found', async () => {
      Order.findById.mockResolvedValue(null);

      await expect(cancelOrder('nonexistent')).rejects.toThrow('Order not found');
    });

    test('throws error when trying to cancel shipped order', async () => {
      const mockOrder = { _id: 'order123', status: 'shipped' };
      Order.findById.mockResolvedValue(mockOrder);

      await expect(cancelOrder('order123')).rejects.toThrow('Cannot cancel shipped order');
    });
  });
});

Key Takeaways

  1. Jest is zero-config: Install, add a test script, and start writing tests -- no plugins needed
  2. Name test files with .test.js or .spec.js so Jest finds them automatically
  3. Structure tests with describe() for grouping and it()/test() for individual cases
  4. Choose the right matcher: toBe for primitives, toEqual for objects, toThrow for errors
  5. Use setup/teardown hooks (beforeEach, afterEach, etc.) to avoid duplicating test setup
  6. Test async code with async/await and rejects.toThrow() for error cases
  7. Mock dependencies with jest.mock() to isolate the code under test
  8. Use jest.fn() for standalone mocks and jest.spyOn() to spy on existing methods
  9. Always clean up mocks with jest.clearAllMocks() in afterEach
  10. Coverage is a guide, not a goal: Aim for 80%+ but focus on meaningful assertions, not just hitting lines

Explain-It Challenge

Scenario: You are building a PaymentService that depends on three external services: StripeAPI (processes credit cards), InventoryService (checks stock availability), and NotificationService (sends receipts). The processPayment(userId, cartItems) function should: (1) check inventory for all items, (2) calculate the total, (3) charge the credit card via Stripe, (4) update inventory counts, and (5) send a receipt notification.

Write the complete test suite for processPayment. How do you mock each dependency? What happens when Stripe fails after inventory was checked? How do you test partial failures? What edge cases would you cover? Write at least 8 test cases.


<< Previous: 3.18.a — Introduction to Testing | Next: 3.18.c — API Testing >>