TDD and BDD for APIs: When Your Web Apps Need to Talk to Each Other

Here is a scenario that will sound familiar to anyone who has worked on distributed systems: you have two web applications that need to communicate. Maybe it is a frontend app talking to a backend API. Maybe it is a main platform integrating with a payments service. Maybe it is a mobile app backend coordinating with a warehouse management system. Whatever the setup, you have got two separate codebases, potentially two separate teams, and an API contract sitting between them. And when that contract breaks, everything falls apart.

Here is a scenario that will sound familiar to anyone who has worked on distributed systems: you have two web applications that need to communicate. Maybe it is a frontend app talking to a backend API. Maybe it is a main platform integrating with a payments service. Maybe it is a mobile app backend coordinating with a warehouse management system. Whatever the setup, you have got two separate codebases, potentially two separate teams, and an API contract sitting between them. And when that contract breaks, everything falls apart.

I have spent years building systems where multiple applications need to coordinate through APIs. The patterns I am about to share come from real production systems, real outages, and real lessons learned the hard way. By the end of this post, you will understand not just how to test APIs between applications, but why certain approaches work better than others and what traps to avoid.

The Problem: Two Apps, One Contract, Zero Guarantees

Let us start by understanding why API testing between separate applications is fundamentally different from testing within a single application.

When you are testing a Rails or Django app in isolation, you control everything. Your models, controllers, views, and tests all live in the same codebase. When you change something, your tests tell you immediately if you broke anything.

But when App A calls App B's API, you have got a completely different situation:

Separate deployment cycles. App A might deploy on Monday, App B on Thursday. What worked on Monday might break on Thursday if App B changed its API.

Separate teams. The team building App A might not even know the team building App B changed something. Communication happens through Slack messages, emails, or worse, production errors.

Separate test suites. App A's tests pass. App B's tests pass. But when they talk to each other in production, everything explodes.

Network unreliability. Unlike function calls within an application, API calls can timeout, fail partially, or return unexpected errors.

Version mismatches. App A might be calling an endpoint that App B deprecated three months ago but never actually removed.

I have seen all of these cause production incidents. The worst was a payments integration where the provider changed a field name from transaction_id to txn_id in their response. Their tests passed. Our tests passed. But we were mocking their responses based on old documentation. Three days of failed payments before anyone noticed.

The Schools of Thought

There are several philosophical approaches to testing APIs between applications. Each has merit, and most production systems use a combination.

School 1: End to End Integration Testing

The most obvious approach: spin up both applications, point them at each other, and run tests that exercise the full flow.

Strengths:

Tests the actual integration, not a simulation.

Catches issues that mocks would miss.

Gives high confidence when tests pass.

Weaknesses:

Slow. Really slow. You need both applications running, databases seeded, potentially external services mocked.

Flaky. Network issues, timing problems, and test data pollution cause intermittent failures.

Expensive. Maintaining integration environments is significant operational overhead.

Late feedback. You often cannot run these until code is deployed to a staging environment.

When to use it:

As a final verification layer before production deployment. Not as your primary testing strategy.

School 2: Mock Everything

The opposite extreme: each application mocks the other's API completely and tests in isolation.

Strengths:

Fast. No network calls, no external dependencies.

Reliable. No flakiness from external systems.

Can run anywhere, including on developer laptops without special setup.

Weaknesses:

Mocks can drift from reality. The mock says the API returns user_id, but the real API returns userId. Your tests pass, production fails.

False confidence. Everything looks green, but you are testing against a fantasy version of the other application.

Maintenance burden. Every API change requires updating mocks in multiple places.

When to use it:

For rapid development and unit testing, but never as your only testing strategy.

School 3: Contract Testing

The middle ground: define a contract that both sides agree to, then test each side against that contract independently.

Strengths:

Fast like mocks, but with guarantees that both sides agree on the interface.

Catches breaking changes before deployment.

Works across language barriers (a Rails app can have a contract with a Django app).

Weaknesses:

Learning curve. Contract testing requires understanding new concepts and tools.

Setup overhead. You need infrastructure to share and verify contracts.

Does not test everything. Business logic on each side still needs separate testing.

When to use it:

As your primary strategy for API integration testing. Supplement with selective end to end tests.

School 4: Consumer Driven Contracts

A specific flavour of contract testing where the consumer (the app calling the API) defines what it needs, and the provider (the app serving the API) verifies it can meet those needs.

Strengths:

Consumers only test what they actually use. If you only need three fields from a 50 field response, you only verify those three.

Providers know exactly what would break if they change something.

Naturally documents which consumers depend on which parts of the API.

Weaknesses:

Requires buy in from both teams.

Contract management becomes a coordination challenge at scale.

Can create resistance to API evolution if providers feel constrained by consumer contracts.

When to use it:

When you have clear consumer/provider relationships and can establish processes for contract sharing.

TDD for APIs: The Rails Way

Let me show you how I approach API testing in Rails, starting with test driven development for an API that another application will consume.

Setting Up Your API Tests

Rails has excellent built in support for API testing through request specs:

# spec/requests/api/v1/orders_spec.rb
require 'rails_helper'

RSpec.describe 'Orders API', type: :request do
  describe 'GET /api/v1/orders/:id' do
    let(:user) { create(:user) }
    let(:order) { create(:order, user: user, total_cents: 4999) }
    
    context 'with valid authentication' do
      let(:headers) do
        {
          'Authorization' => "Bearer #{user.api_token}",
          'Accept' => 'application/json'
        }
      end
      
      it 'returns the order details' do
        get "/api/v1/orders/#{order.id}", headers: headers
        
        expect(response).to have_http_status(:ok)
        expect(response.content_type).to include('application/json')
        
        json = JSON.parse(response.body)
        expect(json['id']).to eq(order.id)
        expect(json['total_cents']).to eq(4999)
        expect(json['status']).to eq('pending')
        expect(json['user_id']).to eq(user.id)
      end
      
      it 'includes line items in the response' do
        line_item = create(:line_item, order: order, quantity: 2)
        
        get "/api/v1/orders/#{order.id}", headers: headers
        
        json = JSON.parse(response.body)
        expect(json['line_items'].length).to eq(1)
        expect(json['line_items'][0]['quantity']).to eq(2)
      end
    end
    
    context 'without authentication' do
      it 'returns unauthorized' do
        get "/api/v1/orders/#{order.id}"
        
        expect(response).to have_http_status(:unauthorized)
      end
    end
    
    context 'when order belongs to different user' do
      let(:other_user) { create(:user) }
      let(:headers) do
        { 'Authorization' => "Bearer #{other_user.api_token}" }
      end
      
      it 'returns not found' do
        get "/api/v1/orders/#{order.id}", headers: headers
        
        # Return 404 not 403 to avoid leaking info about order existence
        expect(response).to have_http_status(:not_found)
      end
    end
  end
  
  describe 'POST /api/v1/orders' do
    let(:user) { create(:user) }
    let(:product) { create(:product, price_cents: 1999) }
    let(:headers) do
      {
        'Authorization' => "Bearer #{user.api_token}",
        'Content-Type' => 'application/json'
      }
    end
    
    let(:valid_params) do
      {
        order: {
          line_items: [
            { product_id: product.id, quantity: 2 }
          ],
          shipping_address: {
            line1: '123 Test Street',
            city: 'London',
            postcode: 'SW1A 1AA'
          }
        }
      }
    end
    
    it 'creates a new order' do
      expect {
        post '/api/v1/orders', params: valid_params.to_json, headers: headers
      }.to change(Order, :count).by(1)
      
      expect(response).to have_http_status(:created)
      
      json = JSON.parse(response.body)
      expect(json['total_cents']).to eq(3998)  # 1999 * 2
      expect(json['status']).to eq('pending')
    end
    
    context 'with invalid params' do
      let(:invalid_params) do
        { order: { line_items: [] } }  # No items
      end
      
      it 'returns validation errors' do
        post '/api/v1/orders', params: invalid_params.to_json, headers: headers
        
        expect(response).to have_http_status(:unprocessable_entity)
        
        json = JSON.parse(response.body)
        expect(json['errors']).to include('line_items')
      end
    end
  end
end

Testing API Versioning

When multiple applications depend on your API, versioning becomes critical:

# spec/requests/api/versioning_spec.rb
require 'rails_helper'

RSpec.describe 'API Versioning', type: :request do
  let(:user) { create(:user) }
  let(:order) { create(:order, user: user) }
  let(:auth_headers) { { 'Authorization' => "Bearer #{user.api_token}" } }
  
  describe 'V1 API' do
    it 'returns the v1 response format' do
      get "/api/v1/orders/#{order.id}", headers: auth_headers
      
      json = JSON.parse(response.body)
      
      # V1 uses snake_case
      expect(json).to have_key('total_cents')
      expect(json).to have_key('created_at')
    end
  end
  
  describe 'V2 API' do
    it 'returns the v2 response format' do
      get "/api/v2/orders/#{order.id}", headers: auth_headers
      
      json = JSON.parse(response.body)
      
      # V2 uses camelCase for JS frontend compatibility
      expect(json).to have_key('totalCents')
      expect(json).to have_key('createdAt')
    end
    
    it 'includes additional fields not in v1' do
      get "/api/v2/orders/#{order.id}", headers: auth_headers
      
      json = JSON.parse(response.body)
      
      # V2 includes estimated delivery
      expect(json).to have_key('estimatedDeliveryDate')
    end
  end
end

RSpec API Documentation with rswag

One of the most powerful tools in the Rails ecosystem is rswag, which generates OpenAPI (Swagger) documentation from your tests:

# spec/requests/api/v1/orders_swagger_spec.rb
require 'swagger_helper'

RSpec.describe 'Orders API', type: :request do
  path '/api/v1/orders/{id}' do
    get 'Retrieves an order' do
      tags 'Orders'
      produces 'application/json'
      parameter name: :id, in: :path, type: :integer, description: 'Order ID'
      parameter name: 'Authorization', in: :header, type: :string, required: true,
                description: 'Bearer token for authentication'
      
      response '200', 'order found' do
        schema type: :object,
               properties: {
                 id: { type: :integer },
                 total_cents: { type: :integer },
                 status: { type: :string, enum: ['pending', 'confirmed', 'shipped', 'delivered'] },
                 user_id: { type: :integer },
                 line_items: {
                   type: :array,
                   items: {
                     type: :object,
                     properties: {
                       id: { type: :integer },
                       product_id: { type: :integer },
                       quantity: { type: :integer },
                       price_cents: { type: :integer }
                     }
                   }
                 },
                 created_at: { type: :string, format: 'date-time' },
                 updated_at: { type: :string, format: 'date-time' }
               },
               required: ['id', 'total_cents', 'status', 'user_id']
        
        let(:user) { create(:user) }
        let(:id) { create(:order, user: user).id }
        let(:Authorization) { "Bearer #{user.api_token}" }
        
        run_test! do |response|
          json = JSON.parse(response.body)
          expect(json['id']).to eq(id)
        end
      end
      
      response '401', 'unauthorized' do
        schema type: :object,
               properties: {
                 error: { type: :string }
               }
        
        let(:id) { create(:order).id }
        let(:Authorization) { nil }
        
        run_test!
      end
      
      response '404', 'order not found' do
        schema type: :object,
               properties: {
                 error: { type: :string }
               }
        
        let(:user) { create(:user) }
        let(:id) { 999999 }
        let(:Authorization) { "Bearer #{user.api_token}" }
        
        run_test!
      end
    end
  end
end

Run rails rswag and you get a complete OpenAPI specification that is guaranteed to match your actual API behaviour because it is generated from tests that pass.

Strengths of rswag:

Documentation is always accurate because tests must pass to generate it.

Generates interactive Swagger UI for API exploration.

Schema validation ensures responses match documented structure.

Integrates naturally with RSpec workflow.

Weaknesses of rswag:

Verbose test syntax. Tests become longer with all the schema definitions.

Learning curve for OpenAPI schema specification.

Can slow down test suite if overused.

TDD for APIs: The Django Way

Django has its own excellent ecosystem for API testing, centred around Django REST Framework (DRF).

Basic API Testing with DRF

# tests/test_orders_api.py
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from orders.models import Order, LineItem
from products.models import Product
from users.models import User


class OrderAPITestCase(APITestCase):
    """
    Test suite for the Orders API endpoints.
    Tests both happy paths and error conditions.
    """
    
    def setUp(self):
        # Create test user and authenticate
        self.user = User.objects.create_user(
            email='[email protected]',
            password='testpass123'
        )
        self.client.force_authenticate(user=self.user)
        
        # Create test product
        self.product = Product.objects.create(
            name='Test Widget',
            price_cents=1999
        )
    
    def test_get_order_returns_order_details(self):
        """GET /api/v1/orders/:id returns full order details"""
        order = Order.objects.create(
            user=self.user,
            total_cents=4999,
            status='pending'
        )
        
        url = reverse('order-detail', kwargs={'pk': order.id})
        response = self.client.get(url)
        
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data['id'], order.id)
        self.assertEqual(response.data['total_cents'], 4999)
        self.assertEqual(response.data['status'], 'pending')
    
    def test_get_order_includes_line_items(self):
        """Order response includes nested line items"""
        order = Order.objects.create(user=self.user, total_cents=3998)
        LineItem.objects.create(
            order=order,
            product=self.product,
            quantity=2,
            price_cents=1999
        )
        
        url = reverse('order-detail', kwargs={'pk': order.id})
        response = self.client.get(url)
        
        self.assertEqual(len(response.data['line_items']), 1)
        self.assertEqual(response.data['line_items'][0]['quantity'], 2)
    
    def test_get_order_without_auth_returns_401(self):
        """Unauthenticated requests are rejected"""
        self.client.force_authenticate(user=None)
        order = Order.objects.create(user=self.user, total_cents=100)
        
        url = reverse('order-detail', kwargs={'pk': order.id})
        response = self.client.get(url)
        
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
    
    def test_get_other_users_order_returns_404(self):
        """Users cannot access other users orders"""
        other_user = User.objects.create_user(
            email='[email protected]',
            password='otherpass'
        )
        other_order = Order.objects.create(user=other_user, total_cents=100)
        
        url = reverse('order-detail', kwargs={'pk': other_order.id})
        response = self.client.get(url)
        
        # 404 not 403 to avoid leaking order existence
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
    
    def test_create_order_with_valid_data(self):
        """POST /api/v1/orders creates new order"""
        url = reverse('order-list')
        data = {
            'line_items': [
                {'product_id': self.product.id, 'quantity': 2}
            ],
            'shipping_address': {
                'line1': '123 Test Street',
                'city': 'London',
                'postcode': 'SW1A 1AA'
            }
        }
        
        response = self.client.post(url, data, format='json')
        
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data['total_cents'], 3998)  # 1999 * 2
        self.assertEqual(Order.objects.count(), 1)
    
    def test_create_order_without_items_fails(self):
        """Orders require at least one line item"""
        url = reverse('order-list')
        data = {'line_items': []}
        
        response = self.client.post(url, data, format='json')
        
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn('line_items', response.data)

Django REST Framework Test Utilities

DRF provides excellent utilities for API testing:

# tests/test_api_utils.py
from rest_framework.test import APIClient, APIRequestFactory
from rest_framework import status
import json


class APITestMixin:
    """
    Mixin providing common API testing utilities.
    Include in your test classes for cleaner test code.
    """
    
    def assertResponseContains(self, response, key, value=None):
        """Assert response JSON contains key, optionally with specific value"""
        data = response.json() if hasattr(response, 'json') else response.data
        self.assertIn(key, data)
        if value is not None:
            self.assertEqual(data[key], value)
    
    def assertResponseStatus(self, response, expected_status):
        """Assert response has expected status with helpful error message"""
        self.assertEqual(
            response.status_code,
            expected_status,
            f"Expected {expected_status}, got {response.status_code}. "
            f"Response: {response.data if hasattr(response, 'data') else response.content}"
        )
    
    def assertValidationError(self, response, field):
        """Assert response contains validation error for specified field"""
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn(field, response.data)
    
    def post_json(self, url, data):
        """POST JSON data and return response"""
        return self.client.post(
            url,
            data=json.dumps(data),
            content_type='application/json'
        )
    
    def put_json(self, url, data):
        """PUT JSON data and return response"""
        return self.client.put(
            url,
            data=json.dumps(data),
            content_type='application/json'
        )

drf-spectacular for Documentation

The Django equivalent of rswag is drf-spectacular, which generates OpenAPI documentation from your DRF views:

# orders/views.py
from rest_framework import viewsets, status
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample
from drf_spectacular.types import OpenApiTypes

from .models import Order
from .serializers import OrderSerializer, OrderCreateSerializer


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    
    def get_queryset(self):
        return Order.objects.filter(user=self.request.user)
    
    @extend_schema(
        summary="Retrieve an order",
        description="Get detailed information about a specific order including line items.",
        responses={
            200: OrderSerializer,
            401: OpenApiTypes.OBJECT,
            404: OpenApiTypes.OBJECT,
        },
        examples=[
            OpenApiExample(
                'Successful retrieval',
                value={
                    'id': 123,
                    'total_cents': 4999,
                    'status': 'pending',
                    'line_items': [
                        {'product_id': 1, 'quantity': 2, 'price_cents': 1999}
                    ]
                },
                response_only=True,
            )
        ]
    )
    def retrieve(self, request, *args, **kwargs):
        return super().retrieve(request, *args, **kwargs)
    
    @extend_schema(
        summary="Create an order",
        description="Create a new order with line items and shipping address.",
        request=OrderCreateSerializer,
        responses={
            201: OrderSerializer,
            400: OpenApiTypes.OBJECT,
            401: OpenApiTypes.OBJECT,
        }
    )
    def create(self, request, *args, **kwargs):
        serializer = OrderCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        order = serializer.save(user=request.user)
        return Response(
            OrderSerializer(order).data,
            status=status.HTTP_201_CREATED
        )

Strengths of drf-spectacular:

Decorator based approach keeps documentation close to code.

Automatic schema inference from serializers.

Excellent support for complex nested structures.

Generates both OpenAPI 3.0 and Swagger 2.0.

Weaknesses of drf-spectacular:

Documentation is not guaranteed to match tests (unlike rswag).

Decorators can become verbose for complex endpoints.

Requires discipline to keep documentation updated.

Contract Testing with Pact

Now let us talk about the most powerful approach for testing APIs between separate applications: contract testing with Pact.

Pact is a consumer driven contract testing framework that works across languages. Your Rails consumer can verify contracts with a Django provider, or vice versa.

How Pact Works

  1. The consumer (the app calling the API) writes tests that describe what it expects from the provider.
  2. These tests generate a contract (a JSON file called a Pact).
  3. The provider (the app serving the API) runs the contract against its actual implementation.
  4. If the provider satisfies all contracts, you know the integration will work.

Consumer Side (Rails App Calling External API)

# spec/service_consumers/warehouse_api_consumer_spec.rb
require 'pact/consumer/rspec'

Pact.service_consumer 'OrdersApp' do
  has_pact_with 'WarehouseAPI' do
    mock_service :warehouse_api do
      port 1234
    end
  end
end

RSpec.describe WarehouseService, pact: true do
  let(:warehouse_service) { WarehouseService.new(base_url: 'http://localhost:1234') }
  
  describe 'create_shipment' do
    before do
      warehouse_api
        .given('a product with SKU WIDGET001 exists')
        .upon_receiving('a request to create a shipment')
        .with(
          method: :post,
          path: '/api/v1/shipments',
          headers: {
            'Content-Type' => 'application/json',
            'Authorization' => Pact.like('Bearer token123')
          },
          body: {
            order_id: Pact.like(12345),
            items: Pact.each_like(
              sku: 'WIDGET001',
              quantity: 2
            ),
            shipping_address: {
              line1: Pact.like('123 Test St'),
              city: Pact.like('London'),
              postcode: Pact.like('SW1A 1AA'),
              country: 'GB'
            }
          }
        )
        .will_respond_with(
          status: 201,
          headers: {
            'Content-Type' => 'application/json'
          },
          body: {
            shipment_id: Pact.like('SHIP123456'),
            status: 'pending',
            estimated_dispatch: Pact.like('2026-04-17'),
            tracking_number: Pact.like('1Z999AA10123456784')
          }
        )
    end
    
    it 'creates a shipment and returns tracking info' do
      result = warehouse_service.create_shipment(
        order_id: 12345,
        items: [{ sku: 'WIDGET001', quantity: 2 }],
        shipping_address: {
          line1: '123 Test St',
          city: 'London',
          postcode: 'SW1A 1AA',
          country: 'GB'
        }
      )
      
      expect(result.shipment_id).to be_present
      expect(result.status).to eq('pending')
      expect(result.tracking_number).to be_present
    end
  end
  
  describe 'get_shipment_status' do
    context 'when shipment exists' do
      before do
        warehouse_api
          .given('a shipment SHIP123 exists')
          .upon_receiving('a request for shipment status')
          .with(
            method: :get,
            path: '/api/v1/shipments/SHIP123',
            headers: {
              'Authorization' => Pact.like('Bearer token123')
            }
          )
          .will_respond_with(
            status: 200,
            body: {
              shipment_id: 'SHIP123',
              status: 'shipped',
              tracking_number: '1Z999AA10123456784',
              shipped_at: Pact.like('2026-04-16T10:30:00Z'),
              carrier: 'UPS'
            }
          )
      end
      
      it 'returns the shipment status' do
        result = warehouse_service.get_shipment_status('SHIP123')
        
        expect(result.status).to eq('shipped')
        expect(result.carrier).to eq('UPS')
      end
    end
    
    context 'when shipment does not exist' do
      before do
        warehouse_api
          .given('no shipment exists with ID NOTFOUND')
          .upon_receiving('a request for non-existent shipment')
          .with(
            method: :get,
            path: '/api/v1/shipments/NOTFOUND'
          )
          .will_respond_with(
            status: 404,
            body: {
              error: 'Shipment not found'
            }
          )
      end
      
      it 'raises a not found error' do
        expect {
          warehouse_service.get_shipment_status('NOTFOUND')
        }.to raise_error(WarehouseService::NotFoundError)
      end
    end
  end
end

Running these tests generates a Pact file that can be shared with the provider.

Provider Side (Django App Serving the API)

# tests/test_pact_provider.py
import pytest
from pact import Verifier
from django.test import TestCase, override_settings
from orders.models import Shipment, Product


@pytest.fixture(scope='session')
def pact_verifier():
    return Verifier(
        provider='WarehouseAPI',
        provider_base_url='http://localhost:8000'
    )


class PactProviderStates:
    """
    Provider states set up the data needed for each Pact interaction.
    These correspond to the 'given' clauses in consumer tests.
    """
    
    @staticmethod
    def setup_state(state_name):
        if state_name == 'a product with SKU WIDGET001 exists':
            Product.objects.get_or_create(
                sku='WIDGET001',
                defaults={'name': 'Test Widget', 'stock': 100}
            )
        
        elif state_name == 'a shipment SHIP123 exists':
            Shipment.objects.get_or_create(
                shipment_id='SHIP123',
                defaults={
                    'status': 'shipped',
                    'tracking_number': '1Z999AA10123456784',
                    'carrier': 'UPS'
                }
            )
        
        elif state_name == 'no shipment exists with ID NOTFOUND':
            Shipment.objects.filter(shipment_id='NOTFOUND').delete()


@pytest.mark.pact
def test_pact_verification(pact_verifier, live_server):
    """
    Verify that our API satisfies all consumer contracts.
    The pact file is fetched from our Pact Broker.
    """
    pact_verifier.verify_with_broker(
        broker_url='https://pact-broker.example.com',
        broker_token='your-token',
        provider_states_setup_url=f'{live_server.url}/_pact/provider_states',
        publish_verification_results=True,
        provider_version='1.0.0'
    )

Strengths of Pact:

Language agnostic. Works across Ruby, Python, JavaScript, Go, and more.

Catches breaking changes before deployment.

Consumer driven approach ensures you only test what is actually used.

Pact Broker provides contract versioning and deployment safety checks.

Weaknesses of Pact:

Significant learning curve.

Requires infrastructure for contract sharing (Pact Broker).

Provider states can become complex to manage.

Does not test business logic, only interface compatibility.

The Dangers: What Can Go Wrong

Let me share some cautionary tales about API testing between applications.

Danger 1: Mock Drift

You mock the external API in your tests. It works. Months pass. The external API changes. Your mocks do not. Your tests still pass, but production breaks.

Solution: Use contract testing. Or at minimum, record real API responses periodically and compare them to your mocks.

Danger 2: False Confidence from Integration Tests

Your integration tests pass in staging. You deploy. Production explodes.

Why? Because staging has different data, different load, different configuration. The integration test proved the code can work, not that it will work.

Solution: Integration tests are necessary but not sufficient. Combine with contract tests, comprehensive unit tests, and production monitoring.

Danger 3: Testing the Happy Path Only

All your API tests check successful responses. But what happens when the API returns 500? Or times out? Or returns malformed JSON? Or takes 30 seconds to respond?

# This is NOT enough
it 'creates a shipment' do
  stub_request(:post, 'https://api.warehouse.com/shipments')
    .to_return(status: 200, body: { shipment_id: '123' }.to_json)
  
  result = service.create_shipment(order)
  expect(result).to be_success
end

# You also need these
it 'handles API errors gracefully' do
  stub_request(:post, 'https://api.warehouse.com/shipments')
    .to_return(status: 500, body: 'Internal Server Error')
  
  result = service.create_shipment(order)
  expect(result).to be_failure
  expect(result.error).to eq('Warehouse API unavailable')
end

it 'handles timeouts gracefully' do
  stub_request(:post, 'https://api.warehouse.com/shipments')
    .to_timeout
  
  result = service.create_shipment(order)
  expect(result).to be_failure
  expect(result.error).to eq('Warehouse API timeout')
end

it 'handles malformed responses gracefully' do
  stub_request(:post, 'https://api.warehouse.com/shipments')
    .to_return(status: 200, body: 'not json')
  
  result = service.create_shipment(order)
  expect(result).to be_failure
  expect(result.error).to eq('Invalid response from Warehouse API')
end

Danger 4: Testing Against Production APIs

I have seen teams run tests against real production APIs. This is dangerous for several reasons:

You might create real data (orders, charges, shipments).

You are dependent on external service availability for your tests.

You might hit rate limits.

You expose test credentials in CI environments.

Solution: Always use sandboxes, mocks, or recorded responses. Never test against production APIs.

Danger 5: Ignoring API Authentication in Tests

# Dangerous - bypassing auth in tests
before do
  allow_any_instance_of(ApiController).to receive(:authenticate!).and_return(true)
end

it 'returns order data' do
  get '/api/orders/1'
  expect(response).to be_successful
end

This test passes, but you have no idea if authentication actually works. Then you deploy and real clients cannot authenticate.

Solution: Test with real authentication flows. Create test tokens properly.

API Tests as Documentation

Here is the payoff for all this testing effort: your API tests become the most reliable documentation you have.

Consider this: traditional API documentation is written once and immediately starts decaying. Someone changes an endpoint but forgets to update the docs. Field names drift. Required parameters become optional. The documentation becomes a liability, actively misleading developers.

But test based documentation cannot lie. If the test says the endpoint returns user_id and it actually returns userId, the test fails. The documentation stays accurate because it is verified on every build.

Generating Documentation from Tests

In Rails with rswag:

# Generate OpenAPI spec from tests
bundle exec rails rswag:specs:swaggerize

# Serve interactive documentation
bundle exec rails s
# Visit http://localhost:3000/api-docs

In Django with drf-spectacular:

# Generate OpenAPI schema
python manage.py spectacular --file schema.yml

# Serve interactive documentation
# Add to urls.py:
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView

urlpatterns = [
    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
    path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
]

Using Pact as Documentation

Pact contracts serve as documentation of exactly what each consumer needs:

{
  "consumer": { "name": "OrdersApp" },
  "provider": { "name": "WarehouseAPI" },
  "interactions": [
    {
      "description": "a request to create a shipment",
      "providerState": "a product with SKU WIDGET001 exists",
      "request": {
        "method": "POST",
        "path": "/api/v1/shipments",
        "body": {
          "order_id": 12345,
          "items": [{"sku": "WIDGET001", "quantity": 2}]
        }
      },
      "response": {
        "status": 201,
        "body": {
          "shipment_id": "SHIP123456",
          "status": "pending"
        }
      }
    }
  ]
}

This tells you exactly what the consumer sends and what it expects back. It is precise, unambiguous, and verified.

Putting It All Together: A Testing Strategy

Based on years of experience, here is my recommended approach for testing APIs between applications:

Layer 1: Unit Tests Test your serializers, validators, and business logic in isolation. Fast, reliable, run on every commit.

Layer 2: Request/Controller Tests Test your API endpoints with real HTTP requests but mocked dependencies. Verify authentication, authorization, validation, and response formats.

Layer 3: Contract Tests Use Pact or similar to verify API contracts between applications. Run on every commit. Block deployments if contracts break.

Layer 4: Integration Tests Selective end to end tests against staging environments. Run before major releases. Focus on critical paths.

Layer 5: Production Monitoring Monitor API health, error rates, and response times in production. Alert on anomalies. This catches what tests cannot.

The key is that each layer catches different types of problems. Unit tests catch logic errors. Contract tests catch interface mismatches. Integration tests catch environmental issues. Production monitoring catches real world edge cases.

Conclusion

Testing APIs between separate web applications is fundamentally different from testing within a single application. The challenges of separate deployments, separate teams, and network unreliability demand specific strategies.

Contract testing, particularly consumer driven contracts with tools like Pact, provides the best balance of speed and reliability. Combined with comprehensive request tests and selective integration tests, you can achieve high confidence that your applications will work together.

And the bonus: all these tests become living documentation that stays accurate because it is executed regularly.

The initial investment in proper API testing pays dividends every time you avoid a production incident, every time a new team member can understand an API by reading its tests, every time you refactor with confidence.

I have been building distributed systems with API integrations for over a decade. The patterns in this post come from real production systems and real outages. The testing strategies are battle tested.

Need help establishing API testing practices between your applications? Or debugging mysterious integration failures? I can help you build systems that communicate reliably and tests that catch problems before production. Let us chat.

Struggling with API integrations between your applications? I can help you establish contract testing, build reliable integration test suites, and create documentation that stays accurate. Get in touch.