Skip to content

End-to-End Testing APIs — Testing Complete Workflows Across Services

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about End. We cover key concepts, practical examples, and best practices to help you master this topic.

End-to-end (E2E) testing for APIs validates complete user workflows across all system components, from authentication through data manipulation to response verification, in a production-like environment.

What You'll Learn

Designing E2E test scenarios, multi-step workflow testing, test environment management, data seeding for E2E tests, and handling asynchronous operations.

Code Example, Practice, FAQ, Mini Project sections follow the standard template.

Code Example: Multi-Step Workflow E2E Test

import requests
import pytest

BASE = "https://staging.durga-antivirus.com"

class TestThreatManagementWorkflow:
    @pytest.fixture
    def session(self):
        s = requests.Session()
        resp = s.post(f"{BASE}/api/auth/login", json={
            "username": "e2e-tester",
            "password": "e2e-test-password"
        })
        assert resp.status_code == 200
        s.headers["Authorization"] = f"Bearer {resp.json()['access_token']}"
        yield s
        # Cleanup: close any investigations
        s.post(f"{BASE}/api/auth/logout")

    def test_full_threat_management_workflow(self, session):
        investigation_id = None
        threat_id = None
        try:
            # Step 1: Create an investigation
            inv_resp = session.post(f"{BASE}/api/v1/investigations", json={
                "title": "E2E Test Investigation",
                "priority": "high",
                "description": "Automated E2E test"
            })
            assert inv_resp.status_code == 201
            investigation_id = inv_resp.json()["id"]

            # Step 2: Add threat indicators
            indicator_resp = session.post(
                f"{BASE}/api/v1/investigations/{investigation_id}/indicators",
                json={
                    "type": "ip_address",
                    "value": "185.220.101.1",
                    "confidence": "high"
                }
            )
            assert indicator_resp.status_code == 201
            indicator_id = indicator_resp.json()["id"]

            # Step 3: Run threat analysis
            analysis_resp = session.post(
                f"{BASE}/api/v1/investigations/{investigation_id}/analyze"
            )
            assert analysis_resp.status_code == 200
            assert "threat_score" in analysis_resp.json()

            # Step 4: Create threat from investigation
            threat_resp = session.post(f"{BASE}/api/v1/threats", json={
                "investigation_id": investigation_id,
                "name": "E2E Test Threat",
                "severity": "high",
                "source_ip": "185.220.101.1"
            })
            assert threat_resp.status_code == 201
            threat_id = threat_resp.json()["id"]

            # Step 5: Verify threat appears in list
            list_resp = session.get(f"{BASE}/api/v1/threats?severity=high")
            assert list_resp.status_code == 200
            threat_ids = [t["id"] for t in list_resp.json()["threats"]]
            assert threat_id in threat_ids

            print(f"E2E workflow completed: investigation={investigation_id}, threat={threat_id}")

        finally:
            # Cleanup
            if threat_id:
                session.delete(f"{BASE}/api/v1/threats/{threat_id}")
            if investigation_id:
                session.delete(f"{BASE}/api/v1/investigations/{investigation_id}")

Code Example: E2E Test for Asynchronous Operations

import time

class TestAsyncWorkflow:
    def wait_for_status(self, session, url, target_status, timeout=30, interval=2):
        start = time.time()
        while time.time() - start < timeout:
            resp = session.get(url)
            assert resp.status_code == 200
            if resp.json()["status"] == target_status:
                return resp.json()
            time.sleep(interval)
        raise TimeoutError(f"Status did not change to {target_status} within {timeout}s")

    def test_async_threat_scan(self, session):
        # Step 1: Submit scan job
        scan_resp = session.post(f"{BASE}/api/v1/scans", json={
            "target": "example.com",
            "scan_type": "full",
            "callback_url": "https://e2e-test.example.com/callback"
        })
        assert scan_resp.status_code == 202
        scan_id = scan_resp.json()["scan_id"]
        assert scan_resp.json()["status"] == "queued"

        # Step 2: Poll for completion
        result = self.wait_for_status(
            session,
            f"{BASE}/api/v1/scans/{scan_id}",
            "completed"
        )

        # Step 3: Verify scan results
        assert "vulnerabilities" in result
        assert "scan_duration" in result
        assert result["scan_duration"] > 0
        print(f"Async scan {scan_id} completed in {result['scan_duration']}s")

        # Step 4: Verify results endpoint
        results_resp = session.get(f"{BASE}/api/v1/scans/{scan_id}/results")
        assert results_resp.status_code == 200
        assert "findings" in results_resp.json()

Code Example: Cross-Service E2E Test

import requests

class TestCrossServiceWorkflow:
    """E2E test involving multiple API services."""

    def test_threat_alert_notification_workflow(self, session):
        # Step 1: Auth service — login
        # Step 2: Threat service — create critical threat
        threat_resp = session.post(f"{BASE}/api/v1/threats", json={
            "name": "Critical E2E Test",
            "severity": "critical",
            "source_ip": "10.0.0.99"
        })
        assert threat_resp.status_code == 201
        threat_id = threat_resp.json()["id"]

        # Step 3: Notification service — verify alert was created
        # (May need to poll for async processing)
        time.sleep(2)
        alerts_resp = session.get(
            f"{BASE}/api/v1/alerts",
            params={"threat_id": threat_id}
        )
        assert alerts_resp.status_code == 200
        alerts = alerts_resp.json()["alerts"]
        assert len(alerts) > 0
        assert alerts[0]["severity"] == "critical"
        assert alerts[0]["type"] == "threat_detected"

        # Step 4: Notification service — verify email notification
        email_resp = session.get(
            f"{BASE}/api/v1/notifications",
            params={"type": "email", "reference_id": threat_id}
        )
        email_log = email_resp.json()
        assert len(email_log) > 0
        assert "security-team@durga.com" in email_log[0]["recipients"]

        # Cleanup
        session.delete(f"{BASE}/api/v1/threats/{threat_id}")

Common Mistakes

1. Hardcoded Environment URLs

E2E tests must run against different environments (dev, staging, production). Use environment variables for base URLs, credentials, and configuration.

2. No Data Cleanup

E2E tests create real data. Always clean up test data in a finally block or use dedicated test accounts with automated cleanup scripts.

3. Flaky Timeouts

Network latency varies. Use polling with configurable timeouts and intervals. Implement exponential back-off for async operations.

4. Testing on Production

E2E tests on production risk data corruption and user disruption. Use staging environments that mirror production configuration.

5. Too Many E2E Tests

E2E tests are slow and expensive. Test only critical user journeys (login, create resource, complete workflow). Cover edge cases with integration tests.

Practice Questions

  1. What makes a good E2E test candidate?
  2. How do you handle asynchronous operations in E2E tests?
  3. Why should E2E tests clean up their data?
  4. What is the difference between E2E and integration tests?
  5. How do you manage test data for E2E tests?

Answers:

  1. Critical user journeys: user registration, payment flow, threat detection workflow. These are the paths where cross-component failures are most damaging.
  2. Use polling with configurable timeouts. Poll the status endpoint until it reaches the expected state. Implement a maximum wait time to prevent infinite loops.
  3. E2E tests create real data in shared environments. Without cleanup, data accumulates, causes test failures, and may trigger alerts or billing charges.
  4. E2E tests verify complete workflows across all services (auth + API + database + notification). Integration tests verify component interactions within a single service.
  5. Use API calls to set up test data (rather than database inserts). This validates the data creation path and ensures the test data is realistic.

Challenge: Build an E2E test suite for a threat intelligence platform covering: user login, threat creation, investigation workflow with threat analysis, alert notification, and data cleanup.

FAQ

Should E2E tests run in CI?

Yes, but only for the staging environment. Run a small subset of critical E2E tests on every commit and the full suite nightly.

How do I handle third-party dependencies in E2E tests?

Use sandbox/test environments for third-party services. If unavailable, mock the third-party at the network level with tools like WireMock.

How long should E2E tests take?

Each E2E test should complete in under 30 seconds. A full suite of 20 E2E tests should complete in under 10 minutes.

Can I run E2E tests in parallel?

Yes, but ensure tests use isolated data (unique IDs, test-specific resources). Parallel tests sharing data will conflict.

What is the role of smoke tests vs E2E tests?

Smoke tests verify the system is running (health checks, basic connectivity). E2E tests verify complete workflows produce correct results.

Mini Project

Build an E2E test suite for a multi-service threat intelligence platform with three API services (auth, threats, notifications). Implement tests for complete workflows, async operation polling, cross-service verification, and automated data cleanup.

What's Next

Now learn about Postman Testing Deep Dive for API testing with the Postman collection runner.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro