Smoke Testing
A minimal test pass that confirms the critical paths of an application work before investing in deeper testing.
When to Activate
- Verifying a fresh deployment to staging or production
- Gating a CI pipeline before the full test suite runs
- Checking core user flows after a release or hotfix
- Building an initial test suite for a new service
- Deciding which tests to run on every commit vs. nightly
- Validating a rollback brought the system back to a working state
- Confirming third-party integrations are reachable after infrastructure changes
What to Test
Smoke tests cover the happy path only — the shortest route through each critical feature.
| Layer | What to check |
|---|---|
| API / Backend | Health endpoint returns 200, auth flow succeeds, main CRUD endpoints respond |
| Frontend | App loads without JS errors, login page renders, primary navigation works |
| Database | Connection succeeds, a simple read query returns data |
| External services | Auth provider, payment gateway, email service are reachable |
| Workers / queues | Worker process starts, picks up a test job, completes without error |
Scope: Smoke vs. Other Test Types
| Test type | Breadth | Depth | Speed | When to run |
|---|---|---|---|---|
| Smoke | Wide | Shallow | Seconds–1 min | Every deploy |
| Sanity | Narrow | Medium | Minutes | After targeted fixes |
| Integration | Medium | Deep | Minutes | Every commit / PR |
| Regression | Full | Deep | Minutes–hours | Nightly / pre-release |
Implementation Examples
Python (pytest + httpx)
import httpx
import pytest
BASE_URL = "https://staging.example.com"
@pytest.fixture(scope="session")
def client():
return httpx.Client(base_url=BASE_URL, timeout=10)
def test_health(client):
r = client.get("/health")
assert r.status_code == 200
def test_login(client):
r = client.post("/auth/login", json={"email": "smoke@test.com", "password": "test"})
assert r.status_code == 200
assert "token" in r.json()
def test_list_users(client):
token = client.post("/auth/login", json={"email": "smoke@test.com", "password": "test"}).json()["token"]
r = client.get("/api/v1/users", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
TypeScript (jest + fetch / supertest)
const BASE = process.env.SMOKE_BASE_URL ?? "http://localhost:3000";
let token: string;
beforeAll(async () => {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "smoke@test.com", password: "test" }),
});
token = (await res.json()).token;
});
test("health check", async () => {
const res = await fetch(`${BASE}/health`);
expect(res.status).toBe(200);
});
test("list users returns 200", async () => {
const res = await fetch(`${BASE}/api/v1/users`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
});
Go (net/http + testing)
package smoke_test
import (
"net/http"
"testing"
)
var base = "https://staging.example.com"
func get(t *testing.T, path string) *http.Response {
t.Helper()
resp, err := http.Get(base + path)
if err != nil {
t.Fatalf("request failed: %v", err)
}
return resp
}
func TestHealth(t *testing.T) {
resp := get(t, "/health")
if resp.StatusCode != http.StatusOK {
t.Errorf("want 200, got %d", resp.StatusCode)
}
}
func TestHomePage(t *testing.T) {
resp := get(t, "/")
if resp.StatusCode != http.StatusOK {
t.Errorf("want 200, got %d", resp.StatusCode)
}
}
CI Integration
Run smoke tests as a dedicated job that gates the full test suite:
# .github/workflows/ci.yml
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run smoke tests
run: pytest tests/smoke/ -m smoke --timeout=60
env:
SMOKE_BASE_URL: ${{ vars.STAGING_URL }}
full-tests:
needs: smoke # only runs if smoke passes
runs-on: ubuntu-latest
steps:
- run: pytest tests/ --ignore=tests/smoke/
Tag smoke tests explicitly so they can run in isolation:
# Python — use pytest marks
@pytest.mark.smoke
def test_health(): ...
# run only smoke
# pytest -m smoke
// TypeScript — use jest testPathPattern or describe label
describe("[smoke]", () => {
test("health", ...);
});
// jest --testNamePattern="\[smoke\]"
Red Flags
- Testing too much — smoke tests that take >5 minutes become a bottleneck and get skipped; keep them under 2 minutes.
- Shared mutable state — smoke tests hitting real data that other tests modify causes false failures; use a dedicated smoke test account.
- No environment isolation — running smoke tests against production instead of staging risks data corruption and real side effects.
- Skipping auth — testing unauthenticated endpoints only misses the most common deployment break (misconfigured secrets/env vars).
- Asserting too loosely — checking only that the response is not
500hides broken payloads; assert on response shape too. - No timeout — smoke tests that hang block the entire pipeline; always set a request and suite-level timeout.
- Duplicating integration tests — smoke tests should not re-assert business logic; if a test checks calculation results it belongs in integration or unit tests.
- Missing notifications — a smoke failure in production that no one sees for hours defeats the purpose; wire failures to alerting (PagerDuty, Slack).
Checklist
- Smoke suite runs end-to-end in under 2 minutes
- Covers health/readiness endpoint, auth flow, and at least one primary resource endpoint
- Tests are tagged (
@pytest.mark.smoke,[smoke],//go:build smoke) so they can run in isolation - Runs automatically on every deploy to staging and production
- Blocks the full test suite in CI via
needs:or equivalent gate - Uses a dedicated test account/fixture data — never touches user production data
- Sets explicit timeouts on requests and the overall suite
- Asserts on HTTP status code AND basic response shape (not just "not 500")
- Failure triggers an immediate alert (Slack, PagerDuty, email)
- Covers at least one critical external dependency (database ping, third-party API reachability)
- Does not duplicate business-logic assertions already covered by integration tests
-
SMOKE_BASE_URL(or equivalent) is configurable via environment variable — no hardcoded URLs