TEST ARCHITECTURE

When One API Test Suite Covers Multiple Environments

The moment a team graduates from "we have some API tests" to "we run those tests against every environment in our pipeline," the suite design has to grow up with it. A test that works against your local dev server but silently lies in staging — because it's hitting the wrong base URL, using hardcoded credentials, or making assumptions about seed data that only exist locally — is worse than no test at all. I've seen teams spend hours chasing a "failing" CI run that was actually a configuration problem, not a product bug.

The core challenge isn't technical complexity — it's discipline. Running one suite across multiple environments means every assumption your tests make about the world has to be externalized. Base URLs, auth tokens, feature flags, timeouts, even the set of tests that are allowed to run in production — all of it needs to live outside the test code itself. When it does, environment-switching becomes a one-liner. When it doesn't, you end up with parallel suite copies that drift apart and contradict each other.

In this article I'll walk through the three architectural moves that make a multi-environment suite reliable: environment-aware configuration, conditional test execution, and environment-specific data strategies. Each one is something you can wire into your existing Python or Behave suite today without a rewrite.

Build an API Automation Framework With Node.js

Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.

Learn more

Environment-Aware Configuration: Keeping Base URLs and Secrets Out of Test Code

The single most important rule for a multi-environment suite: no environment-specific value belongs inside a test file. Not the base URL, not an API key, not a timeout tuned for a slow staging server. The moment one of those values is hardcoded, you have a suite that belongs to exactly one environment.

The pattern I reach for is a layered config approach. At the bottom layer, a YAML or .env file per environment holds the raw values. A thin config loader — a single Python module — reads the target environment from an environment variable (I use TEST_ENV), loads the matching file, and exposes a typed config object to the rest of the suite. Every test that needs a base URL asks the config object, never the file directly.

# config/loader.py
import os, yaml

_ENV = os.getenv("TEST_ENV", "dev")

with open(f"config/{_ENV}.yaml") as f:
    _raw = yaml.safe_load(f)

class Config:
    base_url: str = _raw["base_url"]
    api_key: str  = os.getenv("API_KEY", _raw.get("api_key", ""))
    timeout: int  = _raw.get("timeout", 10)

config = Config()

Notice that api_key prefers an environment variable over the YAML value. That's intentional — secrets should never live in files that get committed. The YAML file can hold a placeholder or be omitted entirely for that key; CI injects the real value. This keeps your config files safe to version-control and your secrets safe everywhere else.

In a Behave suite, the cleanest place to initialize this config is before_all in environment.py, storing it on context:

# features/environment.py
from config.loader import config

def before_all(context):
    context.config = config
    context.base_url = config.base_url

Every step file then reads context.base_url. Switching from dev to staging is just TEST_ENV=staging pytest or TEST_ENV=staging behave — nothing inside the suite changes. This is the same principle behind keeping shared state on the Behave context rather than in module-level globals: one authoritative place, injected at startup.

One practical gotcha: if your environments have meaningfully different timeouts (a staging server behind a VPN is slower than local), put those in the YAML too. Tests that time out in staging but pass in dev will show up as flaky, and the root cause isn't the test — it's a hardcoded timeout that was never right for that environment.

Conditional Test Execution: Not Every Test Should Run in Every Environment

Once configuration is externalized, teams often hit the next wall: some tests simply shouldn't run in certain environments. Destructive tests that create and delete records are fine in dev and staging, but terrifying in production. Tests that depend on third-party sandbox credentials don't work in an environment that uses live credentials. Smoke tests are the only thing you want running against production at 2 AM.

The right tool here is tagging, and the discipline to use it consistently. In pytest, markers do this job. In Behave, tags on scenarios and features do the same. The key is defining a small, stable vocabulary of environment tags up front — before you have fifty tests that are all slightly different — and encoding the rules in one place.

Here's a pattern I've used in pytest that keeps the rules readable:

# conftest.py
import pytest, os

SKIP_IN = {
    "production": ["destructive", "sandbox"],
    "staging":    ["destructive"],
    "dev":        [],
}

def pytest_collection_modifyitems(config, items):
    env = os.getenv("TEST_ENV", "dev")
    skip_tags = SKIP_IN.get(env, [])
    for item in items:
        for tag in skip_tags:
            if tag in item.keywords:
                item.add_marker(
                    pytest.mark.skip(reason=f"skipped in {env}: tagged '{tag}'")
                )

Now any test decorated with @pytest.mark.destructive is automatically skipped when TEST_ENV=production. No per-test if statements, no copy-pasted skip logic. The policy lives in one dictionary.

The Behave equivalent is cleaner still — you pass --tags=~@destructive on the command line, or you encode it in a CI step that sets the tags based on the target environment. Either way, the tests themselves stay clean; the execution policy is external.

This matters especially when your pipeline promotes the same artifact through environments. The test suite that validated staging should be the exact same suite that runs a smoke check on production — just with a different tag filter applied. If you maintain separate suite files per environment, they'll drift. I've watched teams end up with a production smoke suite that no longer tested the things that had actually changed, because the two suites had been edited independently for months.

A related concern: when a dependent external service is unavailable in one environment, you need a strategy beyond just skipping. Handling third-party API downtime gracefully — through mocks, stubs, or explicit skip conditions tied to a health check — is part of the same multi-environment discipline.

Environment-Specific Test Data Without Coupling Your Assertions to a Single Dataset

Configuration and tagging get you most of the way there. The last piece — and the one that bites teams most often — is test data. Dev environments typically have rich, controllable seed data. Staging might have a sanitized copy of production data that's weeks old. Production has real data you can't touch. If your assertions are written against specific IDs or specific record counts, they'll pass in dev and fail everywhere else.

The fix is to write assertions about shape and contract, not about specific values. Instead of asserting that a GET response contains a user with ID 42, assert that the response contains a list where every item has an id field that's an integer and a name field that's a non-empty string. That assertion is true in every environment. This is the same principle behind designing tests that survive an API version bump — decouple your assertions from incidental details, and they stay valid as the world changes around them.

For tests that genuinely need specific data — a test that verifies a known edge case in a specific record — the data should be created by the test itself (or a fixture), not assumed to exist. In pytest, a session-scoped fixture that calls a setup endpoint to create the record, yields the ID, and then calls a teardown endpoint to delete it works well in dev and staging. For production, that fixture should be tagged destructive and skipped, as discussed above.

# conftest.py
import pytest, requests
from config.loader import config

@pytest.fixture(scope="session")
def test_user():
    resp = requests.post(
        f"{config.base_url}/users",
        json={"name": "Suite Test User", "role": "tester"},
        headers={"Authorization": f"Bearer {config.api_key}"},
    )
    resp.raise_for_status()
    user_id = resp.json()["id"]
    yield user_id
    requests.delete(
        f"{config.base_url}/users/{user_id}",
        headers={"Authorization": f"Bearer {config.api_key}"},
    )

One more thing worth calling out: parallelism interacts badly with shared test data. If you're running tests concurrently across environments — or within a single environment — fixtures that create and delete the same named records will collide. Running a parallel suite without flaky failures requires that each worker creates its own isolated data, often by generating unique names or IDs at fixture time rather than using constants.

The through-line across all three of these practices — config externalization, conditional execution, and data isolation — is the same idea: the test code should describe what the API is supposed to do, and the environment should supply everything else. When those two concerns are cleanly separated, running the same suite against dev, staging, and production stops being a source of noise and starts being a genuine signal about whether your software is working.