DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

GitHub Actions for Parallel FastAPI + React Testing: Optimizing CI/CD Feedback Loops in Monorepos

GitHub Actions for Parallel FastAPI + React Testing: Optimizing CI/CD Feedback Loops in Monorepos

I spent three months watching our CI pipeline consume 8 minutes per pull request. Eight minutes. That's long enough to switch contexts, check Slack, and forget what you were debugging. At CitizenApp, we have 9 AI features and 40+ API endpoints—skipping CI isn't an option, but neither is waiting forever for feedback.

The fix wasn't complicated, but it required thinking differently about how GitHub Actions executes jobs. Most teams either run everything sequentially (slow) or parallelize recklessly without understanding database isolation (flaky). I'm going to show you the exact pattern that cut our CI time to 90 seconds and kept our flake rate at zero.

The Problem: Sequential Testing Kills Productivity

Here's what our original workflow looked like:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run FastAPI tests
        run: pytest tests/
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
      - name: Install React dependencies
        run: npm install
      - name: Run React tests
        run: npm run test
Enter fullscreen mode Exit fullscreen mode

This is a single job doing everything sequentially. Python dependencies install while Node sits idle. FastAPI tests run while React dependencies aren't even installed yet. It's like waiting for a single cashier when you have five registers.

The real cost: 8 minutes total, and you're blocked for any feedback.

The Solution: Matrix Strategy + Service Containers

I prefer running backend and frontend tests in separate parallel jobs with isolated PostgreSQL instances. Here's why:

  1. Different environments: Backend needs PostgreSQL; frontend doesn't. Why install both in one job?
  2. Independent scaling: If React tests take 20 seconds and FastAPI tests take 90, parallelize them instead of concatenating.
  3. Service isolation: Each test job gets its own database instance on a unique port. No race conditions, no port conflicts.

Here's the workflow:

name: CI

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

jobs:
  backend-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-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

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run FastAPI tests
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: pytest tests/ -v --cov=src --cov-report=xml

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage.xml

  frontend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run React tests
        run: npm run test:ci

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

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v4
        with:
          python-version: "3.11"
          cache: "pip"
      - run: pip install ruff black
      - run: ruff check .
      - run: black --check .
Enter fullscreen mode Exit fullscreen mode

This runs three jobs in parallel: backend-test, frontend-test, and lint. The PR is blocked only on the slowest job. In our case, that's backend tests at ~90 seconds. Frontend finishes in 25 seconds. Linting takes 8 seconds.

Why Service Containers Matter

The services key in GitHub Actions is magic. It spins up a PostgreSQL container for that job only, with health checks built in. Each test job gets its own isolated database—no test pollution, no port conflicts if you ever run multiple workflows simultaneously.

services:
  postgres:
    image: postgres:16-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
Enter fullscreen mode Exit fullscreen mode

The --health-cmd pg_isready is critical. GitHub Actions waits for this check to pass before running your steps. I learned this the hard way—without it, your test job starts and immediately fails because the database isn't ready yet.

Intelligent Dependency Caching

Notice cache: "pip" and cache: "npm". This is GitHub Actions' built-in caching—it hashes your requirements.txt or package-lock.json and restores dependencies automatically. On subsequent runs, this saves 30–40 seconds alone.

For Python:

- uses: actions/setup-python@v4
  with:
    python-version: "3.11"
    cache: "pip"
Enter fullscreen mode Exit fullscreen mode

For Node:

- uses: actions/setup-node@v4
  with:
    node-version: "20"
    cache: "npm"
Enter fullscreen mode Exit fullscreen mode

This is not optional. Without caching, you're downloading and installing dependencies every single run. That's 3–4 minutes you don't need to waste.

Gotcha: Database Initialization Timing

Here's what burned me initially: I was running pytest immediately after installing dependencies, assuming the PostgreSQL container would be ready. It wasn't. The container was still starting up, and tests failed with "connection refused" errors.

The fix: The --health-cmd and --health-retries options handle this, but you can also add explicit waits in your test setup:

# tests/conftest.py
import time
from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError

def wait_for_db(max_retries=30):
    for attempt in range(max_retries):
        try:
            engine = create_engine(os.getenv("DATABASE_URL"))
            with engine.connect() as conn:
                conn.execute(text("SELECT 1"))
            return
        except OperationalError:
            if attempt == max_retries - 1:
                raise
            time.sleep(1)

@pytest.fixture(scope="session", autouse=True)
def setup_db():
    wait_for_db()
    # Run migrations
    subprocess.run(["alembic", "upgrade", "head"], check=True)
Enter fullscreen mode Exit fullscreen mode

Without this explicit wait, you get flaky tests that pass 80% of the time. Not fun in production.

The Numbers

  • Before: 8 minutes, 2 seconds (sequential)
  • After: 1 minute, 35 seconds (parallel, cached)
  • Improvement: 80% faster

That's not premature optimization—that's respecting developers' time. Every developer on CitizenApp makes 10–15 PRs per week. Over a year, we're saving roughly 200 hours of waiting.

The Takeaway

Parallelization is a force multiplier, but only if you respect dependencies and isolation. Backend and frontend tests have different needs—acknowledge that in your workflow design. Use service containers for database-dependent tests. Cache aggressively. And always, always verify your database is actually ready before you run tests.

Top comments (0)