DEV Community

shreyas shinde
shreyas shinde

Posted on • Originally published at kanaeru.ai on

Testing with Real Services: A Pragmatic Guide to Integration Testing Without Mocks

Listen up, team. I'm Integra, and I'm here to tell you something that might ruffle some feathers: your mock-heavy test suite is giving you a false sense of security. Sure, mocks are fast, predictable, and easy to set up. But they're also lying to you about how your system actually behaves in production.

After years of watching "well-tested" applications crumble in production because their integration points were validated against fantasyland mocks, I've become a staunch advocate for real service testing. Not because I'm a purist, but because I'm pragmatic. I want tests that actually catch the bugs that matter.

In this guide, I'll walk you through the systematic approach to integration testing with real services—the kind that actually tells you if your database queries work, if your API calls succeed, and if your message queues deliver messages. We'll cover environment setup, credential management, cleanup strategies, and how to achieve that sweet spot of 90-95% coverage without burning down your CI/CD pipeline.

Why Real Services Beat Mocks (Most of the Time)

Let's address the elephant in the room first. The testing pyramid, introduced by Mike Cohn in 2009, has guided generations of developers toward a foundation of unit tests with fewer integration tests on top. And that's still sound advice. But here's where teams go wrong: they replace all integration testing with mocked dependencies, thinking they're being efficient.

The Problem with Mock-First Testing

When you mock your database, you're testing your mock, not your database. When you mock your HTTP client, you're validating that you called fetch() correctly, not that the remote API actually returns the data your code expects.

Here's what mocks can't catch:

  • Schema mismatches : Your mock returns user.firstName, but the API actually sends user.first_name
  • Network failures : Timeouts, connection resets, DNS failures—all invisible in mock-land
  • Database constraints : Your mock happily accepts duplicate emails, but PostgreSQL throws a unique constraint violation
  • Authentication flows : OAuth tokens expire, refresh tokens fail, API keys get rate-limited
  • Serialization issues : That JavaScript Date object doesn't serialize the way you think it does

As Philipp Hauer eloquently put it in his 2019 article: "Integration tests test all classes and layers together in the same way as in production. This makes bugs in the integration of classes much more likely to be detected and tests are more meaningful".

When Mocks ARE Appropriate

I'm not a zealot. There are legitimate scenarios for mocks even in integration testing:

  1. Testing failure scenarios : Network simulators like Toxiproxy can inject latency and failures in controlled ways
  2. Third-party services you don't control : If you're integrating with Stripe's production API, you probably want their test mode, not real charges
  3. Slow or expensive operations : If your ML model takes 5 minutes to train, mock the inference in most tests
  4. Isolating specific components : Testing service A's behavior when service B fails? Mock B's responses

The key principle: mock at the boundaries, test the integration.

Setting Up Test Environments That Don't Lie

A test environment that mirrors production is non-negotiable for real service testing. But "mirror production" doesn't mean "duplicate your entire AWS infrastructure." It means having the same types of services with the same interfaces.

The Container Revolution

Thanks to Docker and Testcontainers, we can spin up real databases, message queues, and even complex services in seconds. Here's what a modern test environment looks like:

// testSetup.ts - Environment bootstrapping
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { Pool } from 'pg';
import Redis from 'ioredis';

export class TestEnvironment {
  private postgresContainer: StartedTestContainer;
  private redisContainer: StartedTestContainer;
  private dbPool: Pool;
  private redisClient: Redis;

  async setup(): Promise<void> {
    // Start PostgreSQL with exact production version
    this.postgresContainer = await new GenericContainer('postgres:15-alpine')
      .withEnvironment({
        POSTGRES_USER: 'testuser',
        POSTGRES_PASSWORD: 'testpass',
        POSTGRES_DB: 'testdb',
      })
      .withExposedPorts(5432)
      .start();

    // Start Redis with production configuration
    this.redisContainer = await new GenericContainer('redis:7-alpine')
      .withExposedPorts(6379)
      .start();

    // Initialize real clients
    const pgPort = this.postgresContainer.getMappedPort(5432);
    this.dbPool = new Pool({
      host: 'localhost',
      port: pgPort,
      user: 'testuser',
      password: 'testpass',
      database: 'testdb',
    });

    const redisPort = this.redisContainer.getMappedPort(6379);
    this.redisClient = new Redis({ host: 'localhost', port: redisPort });

    // Run migrations on real database
    await this.runMigrations();
  }

  async cleanup(): Promise<void> {
    await this.dbPool.end();
    await this.redisClient.quit();
    await this.postgresContainer.stop();
    await this.redisContainer.stop();
  }

  getDbPool(): Pool {
    return this.dbPool;
  }

  getRedisClient(): Redis {
    return this.redisClient;
  }

  private async runMigrations(): Promise<void> {
    // Run your actual migration scripts
    // This ensures test DB schema matches production
    const migrationSQL = await readFile('./migrations/001_initial.sql', 'utf-8');
    await this.dbPool.query(migrationSQL);
  }
}

Enter fullscreen mode Exit fullscreen mode

Key insight : Notice we're using the exact same PostgreSQL version as production. Version mismatches are a common source of "works on my machine" bugs.

Environment Configuration Strategy

Your test environment needs different configurations than production, but the same structure. Here's the pattern I recommend:

// config/test.ts
export const testConfig = {
  database: {
    // Provided by Testcontainers at runtime
    host: process.env.TEST_DB_HOST || 'localhost',
    port: parseInt(process.env.TEST_DB_PORT || '5432'),
    // Safe credentials for testing
    user: 'testuser',
    password: 'testpass',
  },

  externalAPIs: {
    // Use sandbox/test modes of real services
    stripe: {
      apiKey: process.env.STRIPE_TEST_KEY, // sk_test_...
      webhookSecret: process.env.STRIPE_TEST_WEBHOOK_SECRET,
    },
    sendgrid: {
      apiKey: process.env.SENDGRID_TEST_KEY,
      // Use SendGrid's sandbox mode
      sandboxMode: true,
    },
  },

  // Feature flags for test scenarios
  features: {
    enableRateLimiting: true, // Test rate limits!
    enableCaching: true, // Test cache invalidation!
    enableRetries: true, // Test retry logic!
  },
};

Enter fullscreen mode Exit fullscreen mode

Managing API Credentials: The Right Way

Here's where many teams stumble: they hardcode test API keys in their codebase or, worse, use production keys in tests. Both are security nightmares.

The Secret Management Hierarchy

  1. Local Development : Use .env.test files (gitignored!) with test credentials
  2. CI/CD Pipelines : Store secrets in your CI provider's vault (GitHub Secrets, GitLab CI/CD variables, etc.)
  3. Shared Test Environments : Use dedicated secret managers (AWS Secrets Manager, HashiCorp Vault)

Here's a robust credential loading pattern:

// lib/testCredentials.ts
import { config } from 'dotenv';

export class TestCredentialManager {
  private credentials: Map<string, string> = new Map();

  constructor() {
    // Load from .env.test if present (local dev)
    config({ path: '.env.test' });

    // Override with CI environment variables if present
    this.loadFromEnvironment();

    // Validate required credentials
    this.validate();
  }

  private loadFromEnvironment(): void {
    const requiredCreds = [
      'STRIPE_TEST_KEY',
      'SENDGRID_TEST_KEY',
      'AWS_TEST_ACCESS_KEY',
      'AWS_TEST_SECRET_KEY',
    ];

    requiredCreds.forEach((key) => {
      const value = process.env[key];
      if (value) {
        this.credentials.set(key, value);
      }
    });
  }

  private validate(): void {
    const missing: string[] = [];

    // Check for essential credentials
    if (!this.credentials.has('STRIPE_TEST_KEY')) {
      missing.push('STRIPE_TEST_KEY');
    }

    if (missing.length > 0) {
      console.warn(
        `⚠️ Missing test credentials: ${missing.join(', ')}\n` +
        `Some integration tests will be skipped.\n` +
        `See README.md for credential setup instructions.`
      );
    }
  }

  get(key: string): string | undefined {
    return this.credentials.get(key);
  }

  has(key: string): boolean {
    return this.credentials.has(key);
  }

  // Fail gracefully when credentials are missing
  requireOrSkip(key: string, testFn: () => void): void {
    if (!this.has(key)) {
      console.log(`⏭️ Skipping test - missing ${key}`);
      return;
    }
    testFn();
  }
}

// Usage in tests
const credManager = new TestCredentialManager();

describe('Stripe Payment Integration', () => {
  it('should process payment with real Stripe API', async () => {
    credManager.requireOrSkip('STRIPE_TEST_KEY', async () => {
      const stripe = new Stripe(credManager.get('STRIPE_TEST_KEY')!);

      const paymentIntent = await stripe.paymentIntents.create({
        amount: 1000,
        currency: 'usd',
        payment_method_types: ['card'],
      });

      expect(paymentIntent.status).toBe('requires_payment_method');
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

Critical principle : Tests should gracefully degrade when credentials are missing, not crash the entire suite. This lets developers run partial test suites locally while CI runs the full battery.

CI/CD Integration Pattern

In your GitHub Actions workflow:

# .github/workflows/test.yml
name: Integration Tests

on: [push, pull_request]

jobs:
  integration-tests:
    runs-on: ubuntu-latest

    env:
      # Inject secrets from GitHub Secrets
      STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
      SENDGRID_TEST_KEY: ${{ secrets.SENDGRID_TEST_KEY }}
      AWS_TEST_ACCESS_KEY: ${{ secrets.AWS_TEST_ACCESS_KEY }}
      AWS_TEST_SECRET_KEY: ${{ secrets.AWS_TEST_SECRET_KEY }}

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run integration tests
        run: npm run test:integration

      - name: Upload coverage reports
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/integration-coverage.json

Enter fullscreen mode Exit fullscreen mode

Cleanup Strategies: The Idempotency Imperative

Here's a truth bomb: if your tests aren't idempotent, they're not reliable. Idempotent tests produce the same results every time they run, regardless of previous executions.

The biggest threat to idempotency? Dirty state. Test A creates a user with email test@example.com, test B assumes that email is available. Test B fails. You debug for an hour before realizing test A didn't clean up.

The Setup-Before Pattern (Recommended)

Contrary to intuition, cleaning up before tests is more reliable than cleaning up after :

// tests/integration/userService.test.ts
describe('UserService Integration', () => {
  let testEnv: TestEnvironment;
  let userService: UserService;

  beforeAll(async () => {
    testEnv = new TestEnvironment();
    await testEnv.setup();
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });

  beforeEach(async () => {
    // CLEAN BEFORE, not after
    // This ensures tests start from known state
    await cleanDatabase(testEnv.getDbPool());

    userService = new UserService(testEnv.getDbPool());
  });

  it('should create user with unique email', async () => {
    const user = await userService.createUser({
      email: 'test@example.com',
      name: 'Test User',
    });

    expect(user.id).toBeDefined();
    expect(user.email).toBe('test@example.com');
  });

  it('should reject duplicate email', async () => {
    await userService.createUser({
      email: 'duplicate@example.com',
      name: 'User One',
    });

    await expect(
      userService.createUser({
        email: 'duplicate@example.com',
        name: 'User Two',
      })
    ).rejects.toThrow('Email already exists');
  });
});

async function cleanDatabase(pool: Pool): Promise<void> {
  // Truncate tables in correct order (respecting foreign keys)
  await pool.query('TRUNCATE users, orders, payments CASCADE');
}

Enter fullscreen mode Exit fullscreen mode

Why cleanup before? If a test crashes mid-execution, the after-cleanup never runs. The database stays dirty. The next test run fails mysteriously. With before-cleanup, every test starts from a known state.

The Try-Finally Pattern for External Services

For external APIs and services you can't easily reset, use try-finally blocks:

it('should send email via SendGrid', async () => {
  const testEmailId = `test-${Date.now()}@example.com`;
  let emailSent = false;

  try {
    // Arrange
    const sendgrid = new SendGridClient(testConfig.sendgridApiKey);

    // Act
    await sendgrid.send({
      to: testEmailId,
      from: 'noreply@example.com',
      subject: 'Test Email',
      text: 'This is a test',
    });
    emailSent = true;

    // Assert
    const emails = await sendgrid.searchEmails({
      to: testEmailId,
      limit: 1,
    });
    expect(emails).toHaveLength(1);

  } finally {
    // Cleanup - even if test fails
    if (emailSent) {
      await sendgrid.deleteEmail(testEmailId);
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

Handling Parallel Test Execution

Modern test runners execute tests in parallel for speed. This is great until test A deletes the user test B is querying. The solution? Data isolation :

// testDataFactory.ts
export class TestDataFactory {
  private static counter = 0;

  static uniqueEmail(): string {
    return `test-${process.pid}-${TestDataFactory.counter++}@example.com`;
  }

  static uniqueUserId(): string {
    return `user-${process.pid}-${TestDataFactory.counter++}`;
  }

  static async createIsolatedUser(pool: Pool): Promise<User> {
    const email = TestDataFactory.uniqueEmail();
    const result = await pool.query(
      'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
      [email, `Test User ${TestDataFactory.counter}`]
    );
    return result.rows[0];
  }
}

// Usage ensures no collisions between parallel tests
it('test A with isolated data', async () => {
  const user = await TestDataFactory.createIsolatedUser(pool);
  // Test uses user, no other test can access this user
});

it('test B with isolated data', async () => {
  const user = await TestDataFactory.createIsolatedUser(pool);
  // Runs in parallel with test A, zero conflicts
});

Enter fullscreen mode Exit fullscreen mode

Testing Error Scenarios: Where Real Services Shine

Mocks make happy-path testing easy. Real services make failure testing possible. And failure testing is where you find the bugs that crash production.

Network Failure Simulation

Tools like Toxiproxy let you inject network failures into real service calls:

import { Toxiproxy } from 'toxiproxy-node-client';

describe('Payment Service - Network Resilience', () => {
  let toxiproxy: Toxiproxy;
  let paymentService: PaymentService;

  beforeAll(async () => {
    toxiproxy = new Toxiproxy('http://localhost:8474');

    // Create proxy for Stripe API
    await toxiproxy.createProxy({
      name: 'stripe_api',
      listen: '0.0.0.0:6789',
      upstream: 'api.stripe.com:443',
    });
  });

  it('should retry on network timeout', async () => {
    // Inject 5-second latency
    await toxiproxy.addToxic({
      proxy: 'stripe_api',
      type: 'latency',
      attributes: { latency: 5000 },
    });

    const start = Date.now();

    await expect(
      paymentService.processPayment({ amount: 1000 })
    ).rejects.toThrow('Request timeout');

    const duration = Date.now() - start;

    // Verify retry logic kicked in (3 retries = ~15 seconds)
    expect(duration).toBeGreaterThan(15000);
  });

  it('should handle connection reset', async () => {
    // Inject connection reset
    await toxiproxy.addToxic({
      proxy: 'stripe_api',
      type: 'reset_peer',
      attributes: { timeout: 0 },
    });

    await expect(
      paymentService.processPayment({ amount: 1000 })
    ).rejects.toThrow('Connection reset');
  });

  afterEach(async () => {
    // Remove toxics between tests
    await toxiproxy.removeToxic({ proxy: 'stripe_api' });
  });
});

Enter fullscreen mode Exit fullscreen mode

Rate Limiting and Throttling

Test how your system handles API rate limits:

it('should respect rate limits', async () => {
  const apiClient = new ExternalAPIClient(testConfig.apiKey);
  const results: Array<'success' | 'throttled'> = [];

  // Hammer the API with 100 requests
  const requests = Array.from({ length: 100 }, async () => {
    try {
      await apiClient.getData();
      results.push('success');
    } catch (error) {
      if (error.statusCode === 429) {
        results.push('throttled');
      } else {
        throw error;
      }
    }
  });

  await Promise.allSettled(requests);

  // Verify rate limiting kicked in
  expect(results.filter(r => r === 'throttled').length).toBeGreaterThan(0);

  // Verify some requests succeeded (we're not completely blocked)
  expect(results.filter(r => r === 'success').length).toBeGreaterThan(0);
});

Enter fullscreen mode Exit fullscreen mode

Achieving 90-95% Coverage: The Pragmatic Target

Let's talk numbers. 100% coverage is a fool's errand—you'll spend more time maintaining tests than writing features. But below 80%, you're flying blind. The sweet spot? 90-95% coverage with a strategic mix of test types.

The Modern Test Distribution

Guillermo Rauch's famous quote: "Write tests. Not too many. Mostly integration". Here's what that looks like in practice:

  • 50-60% Unit Tests : Fast, focused, testing business logic in isolation
  • 30-40% Integration Tests : Real services, testing component interactions
  • 5-10% E2E Tests : Full system tests, critical user journeys

Graphic Suggestion 1 : Modified Testing Pyramid showing integration tests as the strategic middle layer, with callouts for "Real Database," "Real APIs," and "Real Message Queues."

Coverage Gaps to Prioritize

Focus your integration tests on these high-value areas:

  1. Authentication/Authorization flows : Token refresh, permission checks, session management
  2. Data persistence : Database transactions, constraint violations, migrations
  3. External API integrations : Payment processing, email delivery, third-party data
  4. Message queue operations : Event publishing, message consumption, dead-letter handling
  5. Cache invalidation : When does the cache refresh? What happens on cache miss?

Measuring What Matters

Code coverage tools lie. They tell you lines executed, not behaviors validated. Track integration coverage separately:

// package.json
{
  "scripts": {
    "test:unit": "jest --coverage --coverageDirectory=coverage/unit",
    "test:integration": "jest --config=jest.integration.config.js --coverage --coverageDirectory=coverage/integration",
    "test:coverage": "node scripts/mergeCoverage.js"
  }
}


// scripts/mergeCoverage.js
import { mergeCoverageReports } from 'coverage-merge';

const unitCoverage = require('../coverage/unit/coverage-summary.json');
const integrationCoverage = require('../coverage/integration/coverage-summary.json');

const merged = mergeCoverageReports([unitCoverage, integrationCoverage]);

console.log('Combined Coverage Report:');
console.log(`Lines: ${merged.total.lines.pct}%`);
console.log(`Statements: ${merged.total.statements.pct}%`);
console.log(`Functions: ${merged.total.functions.pct}%`);
console.log(`Branches: ${merged.total.branches.pct}%`);

// Fail if below threshold
if (merged.total.lines.pct < 90) {
  console.error('❌ Coverage below 90% threshold');
  process.exit(1);
}

Enter fullscreen mode Exit fullscreen mode

Graphic Suggestion 2 : Coverage dashboard mockup showing unit vs. integration coverage breakdown by module, with integration tests highlighting the "risky" areas (database, external APIs).

CI/CD Integration: Tests That Run Everywhere

Integration tests in CI/CD are tricky. They're slower than unit tests, require infrastructure, and need credentials. But they're also your last line of defense before production.

The Multi-Stage Pipeline

# .github/workflows/full-pipeline.yml
name: Full Test Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run test:unit
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/unit/coverage-final.json
          flags: unit

  integration-tests:
    runs-on: ubuntu-latest
    # Only run on main/develop or when PR is marked ready
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.event.pull_request.draft == false

    services:
      # GitHub Actions provides service containers
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    env:
      TEST_DB_HOST: localhost
      TEST_DB_PORT: 5432
      STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
      SENDGRID_TEST_KEY: ${{ secrets.SENDGRID_TEST_KEY }}

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run db:migrate:test
      - run: npm run test:integration
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/integration/coverage-final.json
          flags: integration

  e2e-tests:
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    # Only run E2E on main branch or when explicitly requested
    if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'run-e2e')

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run test:e2e

Enter fullscreen mode Exit fullscreen mode

Key patterns :

  • Unit tests run on every commit (fast feedback)
  • Integration tests run on main/develop and ready PRs (catch integration bugs before merge)
  • E2E tests run only on main or when explicitly requested (slow but comprehensive)

Graphic Suggestion 3 : CI/CD pipeline flowchart showing the multi-stage approach with conditionals (when to run which tests), including infrastructure setup (containers) and secret injection points.

Optimization: Cached Dependencies

Integration tests that rebuild Docker images every run waste time. Cache aggressively:

- name: Cache Docker layers
  uses: actions/cache@v3
  with:
    path: /tmp/.buildx-cache
    key: ${{ runner.os }}-buildx-${{ hashFiles('**/Dockerfile') }}
    restore-keys: |
      ${{ runner.os }}-buildx-

- name: Pull Docker images
  run: |
    docker pull postgres:15-alpine
    docker pull redis:7-alpine

Enter fullscreen mode Exit fullscreen mode

Parallel Execution in CI

Run independent integration test suites in parallel:

integration-tests:
  strategy:
    matrix:
      test-suite: [database, api, messaging, cache]

  steps:
    - run: npm run test:integration:${{ matrix.test-suite }}

Enter fullscreen mode Exit fullscreen mode

Graphic Suggestion 4 : Test execution timeline showing serial vs. parallel execution, highlighting time savings from running database, API, messaging, and cache tests simultaneously.

Real-World Integration Test Example

Let's put it all together with a realistic e-commerce checkout flow:

// tests/integration/checkout.test.ts
import { TestEnvironment } from '../testSetup';
import { CheckoutService } from '../../src/services/CheckoutService';
import { StripePaymentProcessor } from '../../src/payments/StripePaymentProcessor';
import { SendGridEmailService } from '../../src/email/SendGridEmailService';
import { TestDataFactory } from '../testDataFactory';
import { TestCredentialManager } from '../testCredentials';

describe('Checkout Integration', () => {
  let testEnv: TestEnvironment;
  let checkoutService: CheckoutService;
  let credManager: TestCredentialManager;

  beforeAll(async () => {
    testEnv = new TestEnvironment();
    await testEnv.setup();
    credManager = new TestCredentialManager();
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });

  beforeEach(async () => {
    // Clean state before each test
    await testEnv.getDbPool().query('TRUNCATE orders, payments, users CASCADE');
  });

  it('should complete full checkout with real payment and email', async () => {
    credManager.requireOrSkip('STRIPE_TEST_KEY', async () => {
      credManager.requireOrSkip('SENDGRID_TEST_KEY', async () => {
        // Arrange: Create test user with isolated data
        const user = await TestDataFactory.createIsolatedUser(testEnv.getDbPool());

        const paymentProcessor = new StripePaymentProcessor(
          credManager.get('STRIPE_TEST_KEY')!
        );

        const emailService = new SendGridEmailService(
          credManager.get('SENDGRID_TEST_KEY')!
        );

        checkoutService = new CheckoutService(
          testEnv.getDbPool(),
          paymentProcessor,
          emailService
        );

        const cart = {
          items: [
            { productId: 'prod_123', quantity: 2, price: 1999 },
            { productId: 'prod_456', quantity: 1, price: 4999 },
          ],
        };

        let orderId: string;

        try {
          // Act: Process checkout with REAL Stripe payment
          const result = await checkoutService.processCheckout({
            userId: user.id,
            cart,
            paymentMethod: {
              type: 'card',
              cardToken: 'tok_visa', // Stripe test token
            },
          });

          orderId = result.orderId;

          // Assert: Verify order created in REAL database
          const orderResult = await testEnv.getDbPool().query(
            'SELECT * FROM orders WHERE id = $1',
            [orderId]
          );
          expect(orderResult.rows).toHaveLength(1);
          expect(orderResult.rows[0].status).toBe('completed');
          expect(orderResult.rows[0].total_amount).toBe(8997);

          // Assert: Verify payment recorded
          const paymentResult = await testEnv.getDbPool().query(
            'SELECT * FROM payments WHERE order_id = $1',
            [orderId]
          );
          expect(paymentResult.rows).toHaveLength(1);
          expect(paymentResult.rows[0].status).toBe('succeeded');
          expect(paymentResult.rows[0].provider).toBe('stripe');

          // Assert: Verify email sent via REAL SendGrid
          const emails = await emailService.searchEmails({
            to: user.email,
            subject: 'Order Confirmation',
            limit: 1,
          });
          expect(emails).toHaveLength(1);
          expect(emails[0].body).toContain(orderId);

        } finally {
          // Cleanup: Cancel order and refund payment
          if (orderId) {
            await checkoutService.cancelOrder(orderId);
          }
        }
      });
    });
  });

  it('should handle payment failure gracefully', async () => {
    credManager.requireOrSkip('STRIPE_TEST_KEY', async () => {
      const user = await TestDataFactory.createIsolatedUser(testEnv.getDbPool());

      const paymentProcessor = new StripePaymentProcessor(
        credManager.get('STRIPE_TEST_KEY')!
      );

      checkoutService = new CheckoutService(
        testEnv.getDbPool(),
        paymentProcessor,
        new SendGridEmailService(credManager.get('SENDGRID_TEST_KEY')!)
      );

      const cart = {
        items: [{ productId: 'prod_789', quantity: 1, price: 9999 }],
      };

      // Act: Use Stripe's test token for declined card
      await expect(
        checkoutService.processCheckout({
          userId: user.id,
          cart,
          paymentMethod: {
            type: 'card',
            cardToken: 'tok_chargeDeclined', // Stripe test token for declined
          },
        })
      ).rejects.toThrow('Payment declined');

      // Assert: Verify order marked as failed
      const orderResult = await testEnv.getDbPool().query(
        'SELECT * FROM orders WHERE user_id = $1',
        [user.id]
      );
      expect(orderResult.rows).toHaveLength(1);
      expect(orderResult.rows[0].status).toBe('payment_failed');

      // Assert: No successful payment recorded
      const paymentResult = await testEnv.getDbPool().query(
        'SELECT * FROM payments WHERE status = $1',
        ['succeeded']
      );
      expect(paymentResult.rows).toHaveLength(0);
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

This test validates:

  • Real PostgreSQL database operations (order creation, payment recording)
  • Real Stripe payment processing (using their test mode)
  • Real SendGrid email delivery (using sandbox mode)
  • Proper error handling with failed payments
  • Complete cleanup even on test failure

Graphic Suggestion 5 : Sequence diagram of the checkout flow showing interactions between test code → database → Stripe API → SendGrid API, with annotations for assertion points and cleanup steps.

Common Pitfalls and How to Avoid Them

After years of real-service testing, here are the traps I see teams fall into:

Pitfall 1: Flaky Tests Due to Timing

Problem : Test passes locally, fails in CI randomly.

Solution : Never use arbitrary timeouts. Use explicit waits:

// ❌ Bad: Arbitrary timeout
await sleep(1000);
expect(order.status).toBe('completed');

// ✅ Good: Wait for condition
await waitFor(
  async () => {
    const order = await getOrder(orderId);
    return order.status === 'completed';
  },
  { timeout: 5000, interval: 100 }
);

Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Test Data Pollution

Problem : Tests interfere with each other, random failures.

Solution : Unique identifiers + cleanup before tests (as shown earlier).

Pitfall 3: Ignoring Test Performance

Problem : Integration suite takes 30 minutes, developers stop running it.

Solution : Parallelize, cache dependencies, and set time budgets:

// jest.integration.config.js
module.exports = {
  testTimeout: 10000, // 10 seconds max per test
  maxWorkers: '50%', // Use half CPU cores for parallel execution
  setupFilesAfterEnv: ['<rootDir>/tests/testSetup.ts'],
};

Enter fullscreen mode Exit fullscreen mode

If a test exceeds 10 seconds, it needs optimization or should become an E2E test.

Pitfall 4: Over-Testing Edge Cases

Problem : 1000 tests, 90% test the same happy path.

Solution : Use test matrices for edge cases:

describe.each([
  { input: 'valid@email.com', expected: true },
  { input: 'invalid', expected: false },
  { input: 'no@domain', expected: false },
  { input: '', expected: false },
  { input: null, expected: false },
])('Email validation', ({ input, expected }) => {
  it(`should return ${expected} for "${input}"`, async () => {
    const result = await validateEmail(input);
    expect(result).toBe(expected);
  });
});

Enter fullscreen mode Exit fullscreen mode

The Bottom Line: Tests That Earn Trust

Real service testing isn't about perfection. It's about confidence. When your integration tests pass, you should feel comfortable deploying to production. When they fail, you should trust that they caught a real bug, not a mock mismatch.

Here's my systematic checklist for building that confidence:

  1. Environment Setup : Use containers to mirror production services
  2. Credential Management : Secure secrets, graceful degradation when missing
  3. Cleanup Strategy : Clean before tests, use try-finally for external services
  4. Data Isolation : Unique identifiers to prevent test interference
  5. Error Scenarios : Test failures, timeouts, rate limits with real service simulation
  6. Coverage Target : Aim for 90-95% with strategic test distribution
  7. CI/CD Integration : Multi-stage pipeline with caching and parallelization

Integration testing with real services requires more setup than mocks. It's slower. It's more complex. But when done right, it's the difference between "we think it works" and "we know it works."

Now go forth and test with real databases, real APIs, and real confidence.

Integration Testing Architecture

The Modified Test Pyramid for Real Services

While the traditional test pyramid emphasizes unit tests at the base, real-service integration testing requires a different balance:

Diagram 1

Integration tests take a larger share when testing complex external service interactions.

Real Service Test Environment Flow

A production-grade integration test follows this lifecycle:

Diagram 2

This ensures tests are isolated and idempotent, running reliably in CI/CD pipelines.


References

: [1] Cohn, M. (2009). Succeeding with Agile: Software Development Using Scrum. The Testing Pyramid

: [2] Hauer, P. (2019). Focus on Integration Tests Instead of Mock-Based Tests. https://phauer.com/2019/focus-integration-tests-mock-based-tests/

: [3] Hauer, P. (2019). Integration testing tools and practices. Focus on Integration Tests Instead of Mock-Based Tests

: [4] Stack Overflow Community. (2018). Is it considered a good practice to mock in integration tests? https://stackoverflow.com/questions/52107522/

: [5] Server Fault Community. Credentials management within CI/CD environment. https://serverfault.com/questions/924431/

: [6] Rojek, M. (2021). Idempotence in Software Testing. https://medium.com/@rojek.mac/idempotence-in-software-testing-b8fd946320c5

: [7] Software Engineering Stack Exchange. Cleanup & Arrange practices during integration testing to avoid dirty databases. https://softwareengineering.stackexchange.com/questions/308666/

: [8] Stack Overflow Community. What strategy to use with xUnit for integration tests when knowing they run in parallel? https://stackoverflow.com/questions/55297811/

: [9] LinearB. Test Coverage Demystified: A Complete Introductory Guide. https://linearb.io/blog/test-coverage-demystified

: [10] Web.dev. Pyramid or Crab? Find a testing strategy that fits. https://web.dev/articles/ta-strategies


Originally published at kanaeru.ai

Top comments (0)