Files
pig-farm-controller/bmad/bmm/testarch/knowledge/selective-testing.md
2025-11-01 19:22:39 +08:00

21 KiB

Selective and Targeted Test Execution

Principle

Run only the tests you need, when you need them. Use tags/grep to slice suites by risk priority (not directory structure), filter by spec patterns or git diff to focus on impacted areas, and combine priority metadata (P0-P3) with change detection to optimize pre-commit vs. CI execution. Document the selection strategy clearly so teams understand when full regression is mandatory.

Rationale

Running the entire test suite on every commit wastes time and resources. Smart test selection provides fast feedback (smoke tests in minutes, full regression in hours) while maintaining confidence. The "32+ ways of selective testing" philosophy balances speed with coverage: quick loops for developers, comprehensive validation before deployment. Poorly documented selection leads to confusion about when tests run and why.

Pattern Examples

Example 1: Tag-Based Execution with Priority Levels

Context: Organize tests by risk priority and execution stage using grep/tag patterns.

Implementation:

// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';

/**
 * Tag-based test organization
 * - @smoke: Critical path tests (run on every commit, < 5 min)
 * - @regression: Full test suite (run pre-merge, < 30 min)
 * - @p0: Critical business functions (payment, auth, data integrity)
 * - @p1: Core features (primary user journeys)
 * - @p2: Secondary features (supporting functionality)
 * - @p3: Nice-to-have (cosmetic, non-critical)
 */

test.describe('Checkout Flow', () => {
  // P0 + Smoke: Must run on every commit
  test('@smoke @p0 should complete purchase with valid payment', async ({ page }) => {
    await page.goto('/checkout');
    await page.getByTestId('card-number').fill('4242424242424242');
    await page.getByTestId('submit-payment').click();

    await expect(page.getByTestId('order-confirmation')).toBeVisible();
  });

  // P0 but not smoke: Run pre-merge
  test('@regression @p0 should handle payment decline gracefully', async ({ page }) => {
    await page.goto('/checkout');
    await page.getByTestId('card-number').fill('4000000000000002'); // Decline card
    await page.getByTestId('submit-payment').click();

    await expect(page.getByTestId('payment-error')).toBeVisible();
    await expect(page.getByTestId('payment-error')).toContainText('declined');
  });

  // P1 + Smoke: Important but not critical
  test('@smoke @p1 should apply discount code', async ({ page }) => {
    await page.goto('/checkout');
    await page.getByTestId('promo-code').fill('SAVE10');
    await page.getByTestId('apply-promo').click();

    await expect(page.getByTestId('discount-applied')).toBeVisible();
  });

  // P2: Run in full regression only
  test('@regression @p2 should remember saved payment methods', async ({ page }) => {
    await page.goto('/checkout');
    await expect(page.getByTestId('saved-cards')).toBeVisible();
  });

  // P3: Low priority, run nightly or weekly
  test('@nightly @p3 should display checkout page analytics', async ({ page }) => {
    await page.goto('/checkout');
    const analyticsEvents = await page.evaluate(() => (window as any).__ANALYTICS__);
    expect(analyticsEvents).toBeDefined();
  });
});

package.json scripts:

{
  "scripts": {
    "test": "playwright test",
    "test:smoke": "playwright test --grep '@smoke'",
    "test:p0": "playwright test --grep '@p0'",
    "test:p0-p1": "playwright test --grep '@p0|@p1'",
    "test:regression": "playwright test --grep '@regression'",
    "test:nightly": "playwright test --grep '@nightly'",
    "test:not-slow": "playwright test --grep-invert '@slow'",
    "test:critical-smoke": "playwright test --grep '@smoke.*@p0'"
  }
}

Cypress equivalent:

// cypress/e2e/checkout.cy.ts
describe('Checkout Flow', { tags: ['@checkout'] }, () => {
  it('should complete purchase', { tags: ['@smoke', '@p0'] }, () => {
    cy.visit('/checkout');
    cy.get('[data-cy="card-number"]').type('4242424242424242');
    cy.get('[data-cy="submit-payment"]').click();
    cy.get('[data-cy="order-confirmation"]').should('be.visible');
  });

  it('should handle decline', { tags: ['@regression', '@p0'] }, () => {
    cy.visit('/checkout');
    cy.get('[data-cy="card-number"]').type('4000000000000002');
    cy.get('[data-cy="submit-payment"]').click();
    cy.get('[data-cy="payment-error"]').should('be.visible');
  });
});

// cypress.config.ts
export default defineConfig({
  e2e: {
    env: {
      grepTags: process.env.GREP_TAGS || '',
      grepFilterSpecs: true,
    },
    setupNodeEvents(on, config) {
      require('@cypress/grep/src/plugin')(config);
      return config;
    },
  },
});

Usage:

# Playwright
npm run test:smoke                    # Run all @smoke tests
npm run test:p0                       # Run all P0 tests
npm run test -- --grep "@smoke.*@p0"  # Run tests with BOTH tags

# Cypress (with @cypress/grep plugin)
npx cypress run --env grepTags="@smoke"
npx cypress run --env grepTags="@p0+@smoke"  # AND logic
npx cypress run --env grepTags="@p0 @p1"     # OR logic

Key Points:

  • Multiple tags per test: Combine priority (@p0) with stage (@smoke)
  • AND/OR logic: Grep supports complex filtering
  • Clear naming: Tags document test importance
  • Fast feedback: @smoke runs < 5 min, full suite < 30 min
  • CI integration: Different jobs run different tag combinations

Example 2: Spec Filter Pattern (File-Based Selection)

Context: Run tests by file path pattern or directory for targeted execution.

Implementation:

#!/bin/bash
# scripts/selective-spec-runner.sh
# Run tests based on spec file patterns

set -e

PATTERN=${1:-"**/*.spec.ts"}
TEST_ENV=${TEST_ENV:-local}

echo "🎯 Selective Spec Runner"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Pattern: $PATTERN"
echo "Environment: $TEST_ENV"
echo ""

# Pattern examples and their use cases
case "$PATTERN" in
  "**/checkout*")
    echo "📦 Running checkout-related tests"
    npx playwright test --grep-files="**/checkout*"
    ;;
  "**/auth*"|"**/login*"|"**/signup*")
    echo "🔐 Running authentication tests"
    npx playwright test --grep-files="**/auth*|**/login*|**/signup*"
    ;;
  "tests/e2e/**")
    echo "🌐 Running all E2E tests"
    npx playwright test tests/e2e/
    ;;
  "tests/integration/**")
    echo "🔌 Running all integration tests"
    npx playwright test tests/integration/
    ;;
  "tests/component/**")
    echo "🧩 Running all component tests"
    npx playwright test tests/component/
    ;;
  *)
    echo "🔍 Running tests matching pattern: $PATTERN"
    npx playwright test "$PATTERN"
    ;;
esac

Playwright config for file filtering:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // ... other config

  // Project-based organization
  projects: [
    {
      name: 'smoke',
      testMatch: /.*smoke.*\.spec\.ts/,
      retries: 0,
    },
    {
      name: 'e2e',
      testMatch: /tests\/e2e\/.*\.spec\.ts/,
      retries: 2,
    },
    {
      name: 'integration',
      testMatch: /tests\/integration\/.*\.spec\.ts/,
      retries: 1,
    },
    {
      name: 'component',
      testMatch: /tests\/component\/.*\.spec\.ts/,
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Advanced pattern matching:

// scripts/run-by-component.ts
/**
 * Run tests related to specific component(s)
 * Usage: npm run test:component UserProfile,Settings
 */

import { execSync } from 'child_process';

const components = process.argv[2]?.split(',') || [];

if (components.length === 0) {
  console.error('❌ No components specified');
  console.log('Usage: npm run test:component UserProfile,Settings');
  process.exit(1);
}

// Convert component names to glob patterns
const patterns = components.map((comp) => `**/*${comp}*.spec.ts`).join(' ');

console.log(`🧩 Running tests for components: ${components.join(', ')}`);
console.log(`Patterns: ${patterns}`);

try {
  execSync(`npx playwright test ${patterns}`, {
    stdio: 'inherit',
    env: { ...process.env, CI: 'false' },
  });
} catch (error) {
  process.exit(1);
}

package.json scripts:

{
  "scripts": {
    "test:checkout": "playwright test **/checkout*.spec.ts",
    "test:auth": "playwright test **/auth*.spec.ts **/login*.spec.ts",
    "test:e2e": "playwright test tests/e2e/",
    "test:integration": "playwright test tests/integration/",
    "test:component": "ts-node scripts/run-by-component.ts",
    "test:project": "playwright test --project",
    "test:smoke-project": "playwright test --project smoke"
  }
}

Key Points:

  • Glob patterns: Wildcards match file paths flexibly
  • Project isolation: Separate projects have different configs
  • Component targeting: Run tests for specific features
  • Directory-based: Organize tests by type (e2e, integration, component)
  • CI optimization: Run subsets in parallel CI jobs

Example 3: Diff-Based Test Selection (Changed Files Only)

Context: Run only tests affected by code changes for maximum speed.

Implementation:

#!/bin/bash
# scripts/test-changed-files.sh
# Intelligent test selection based on git diff

set -e

BASE_BRANCH=${BASE_BRANCH:-main}
TEST_ENV=${TEST_ENV:-local}

echo "🔍 Changed File Test Selector"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Base branch: $BASE_BRANCH"
echo "Environment: $TEST_ENV"
echo ""

# Get changed files
CHANGED_FILES=$(git diff --name-only $BASE_BRANCH...HEAD)

if [ -z "$CHANGED_FILES" ]; then
  echo "✅ No files changed. Skipping tests."
  exit 0
fi

echo "Changed files:"
echo "$CHANGED_FILES" | sed 's/^/  - /'
echo ""

# Arrays to collect test specs
DIRECT_TEST_FILES=()
RELATED_TEST_FILES=()
RUN_ALL_TESTS=false

# Process each changed file
while IFS= read -r file; do
  case "$file" in
    # Changed test files: run them directly
    *.spec.ts|*.spec.js|*.test.ts|*.test.js|*.cy.ts|*.cy.js)
      DIRECT_TEST_FILES+=("$file")
      ;;

    # Critical config changes: run ALL tests
    package.json|package-lock.json|playwright.config.ts|cypress.config.ts|tsconfig.json|.github/workflows/*)
      echo "⚠️  Critical file changed: $file"
      RUN_ALL_TESTS=true
      break
      ;;

    # Component changes: find related tests
    src/components/*.tsx|src/components/*.jsx)
      COMPONENT_NAME=$(basename "$file" | sed 's/\.[^.]*$//')
      echo "🧩 Component changed: $COMPONENT_NAME"

      # Find tests matching component name
      FOUND_TESTS=$(find tests -name "*${COMPONENT_NAME}*.spec.ts" -o -name "*${COMPONENT_NAME}*.cy.ts" 2>/dev/null || true)
      if [ -n "$FOUND_TESTS" ]; then
        while IFS= read -r test_file; do
          RELATED_TEST_FILES+=("$test_file")
        done <<< "$FOUND_TESTS"
      fi
      ;;

    # Utility/lib changes: run integration + unit tests
    src/utils/*|src/lib/*|src/helpers/*)
      echo "⚙️  Utility file changed: $file"
      RELATED_TEST_FILES+=($(find tests/unit tests/integration -name "*.spec.ts" 2>/dev/null || true))
      ;;

    # API changes: run integration + e2e tests
    src/api/*|src/services/*|src/controllers/*)
      echo "🔌 API file changed: $file"
      RELATED_TEST_FILES+=($(find tests/integration tests/e2e -name "*.spec.ts" 2>/dev/null || true))
      ;;

    # Type changes: run all TypeScript tests
    *.d.ts|src/types/*)
      echo "📝 Type definition changed: $file"
      RUN_ALL_TESTS=true
      break
      ;;

    # Documentation only: skip tests
    *.md|docs/*|README*)
      echo "📄 Documentation changed: $file (no tests needed)"
      ;;

    *)
      echo "❓ Unclassified change: $file (running smoke tests)"
      RELATED_TEST_FILES+=($(find tests -name "*smoke*.spec.ts" 2>/dev/null || true))
      ;;
  esac
done <<< "$CHANGED_FILES"

# Execute tests based on analysis
if [ "$RUN_ALL_TESTS" = true ]; then
  echo ""
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  echo "🚨 Running FULL test suite (critical changes detected)"
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  npm run test
  exit $?
fi

# Combine and deduplicate test files
ALL_TEST_FILES=(${DIRECT_TEST_FILES[@]} ${RELATED_TEST_FILES[@]})
UNIQUE_TEST_FILES=($(echo "${ALL_TEST_FILES[@]}" | tr ' ' '\n' | sort -u))

if [ ${#UNIQUE_TEST_FILES[@]} -eq 0 ]; then
  echo ""
  echo "✅ No tests found for changed files. Running smoke tests."
  npm run test:smoke
  exit $?
fi

echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎯 Running ${#UNIQUE_TEST_FILES[@]} test file(s)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

for test_file in "${UNIQUE_TEST_FILES[@]}"; do
  echo "  - $test_file"
done

echo ""
npm run test -- "${UNIQUE_TEST_FILES[@]}"

GitHub Actions integration:

# .github/workflows/test-changed.yml
name: Test Changed Files
on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  detect-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Full history for accurate diff

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v40
        with:
          files: |
            src/**
            tests/**
            *.config.ts
          files_ignore: |
            **/*.md
            docs/**

      - name: Run tests for changed files
        if: steps.changed-files.outputs.any_changed == 'true'
        run: |
          echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}"
          bash scripts/test-changed-files.sh
        env:
          BASE_BRANCH: ${{ github.base_ref }}
          TEST_ENV: staging

Key Points:

  • Intelligent mapping: Code changes → related tests
  • Critical file detection: Config changes = full suite
  • Component mapping: UI changes → component + E2E tests
  • Fast feedback: Run only what's needed (< 2 min typical)
  • Safety net: Unrecognized changes run smoke tests

Example 4: Promotion Rules (Pre-Commit → CI → Staging → Production)

Context: Progressive test execution strategy across deployment stages.

Implementation:

// scripts/test-promotion-strategy.ts
/**
 * Test Promotion Strategy
 * Defines which tests run at each stage of the development lifecycle
 */

export type TestStage = 'pre-commit' | 'ci-pr' | 'ci-merge' | 'staging' | 'production';

export type TestPromotion = {
  stage: TestStage;
  description: string;
  testCommand: string;
  timebudget: string; // minutes
  required: boolean;
  failureAction: 'block' | 'warn' | 'alert';
};

export const TEST_PROMOTION_RULES: Record<TestStage, TestPromotion> = {
  'pre-commit': {
    stage: 'pre-commit',
    description: 'Local developer checks before git commit',
    testCommand: 'npm run test:smoke',
    timebudget: '2',
    required: true,
    failureAction: 'block',
  },
  'ci-pr': {
    stage: 'ci-pr',
    description: 'CI checks on pull request creation/update',
    testCommand: 'npm run test:changed && npm run test:p0-p1',
    timebudget: '10',
    required: true,
    failureAction: 'block',
  },
  'ci-merge': {
    stage: 'ci-merge',
    description: 'Full regression before merge to main',
    testCommand: 'npm run test:regression',
    timebudget: '30',
    required: true,
    failureAction: 'block',
  },
  staging: {
    stage: 'staging',
    description: 'Post-deployment validation in staging environment',
    testCommand: 'npm run test:e2e -- --grep "@smoke"',
    timebudget: '15',
    required: true,
    failureAction: 'block',
  },
  production: {
    stage: 'production',
    description: 'Production smoke tests post-deployment',
    testCommand: 'npm run test:e2e:prod -- --grep "@smoke.*@p0"',
    timebudget: '5',
    required: false,
    failureAction: 'alert',
  },
};

/**
 * Get tests to run for a specific stage
 */
export function getTestsForStage(stage: TestStage): TestPromotion {
  return TEST_PROMOTION_RULES[stage];
}

/**
 * Validate if tests can be promoted to next stage
 */
export function canPromote(currentStage: TestStage, testsPassed: boolean): boolean {
  const promotion = TEST_PROMOTION_RULES[currentStage];

  if (!promotion.required) {
    return true; // Non-required tests don't block promotion
  }

  return testsPassed;
}

Husky pre-commit hook:

#!/bin/bash
# .husky/pre-commit
# Run smoke tests before allowing commit

echo "🔍 Running pre-commit tests..."

npm run test:smoke

if [ $? -ne 0 ]; then
  echo ""
  echo "❌ Pre-commit tests failed!"
  echo "Please fix failures before committing."
  echo ""
  echo "To skip (NOT recommended): git commit --no-verify"
  exit 1
fi

echo "✅ Pre-commit tests passed"

GitHub Actions workflow:

# .github/workflows/test-promotion.yml
name: Test Promotion Strategy
on:
  pull_request:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  # Stage 1: PR tests (changed + P0-P1)
  pr-tests:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - name: Run PR-level tests
        run: |
          npm run test:changed
          npm run test:p0-p1

  # Stage 2: Full regression (pre-merge)
  regression-tests:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - name: Run full regression
        run: npm run test:regression

  # Stage 3: Staging validation (post-deploy)
  staging-smoke:
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - name: Run staging smoke tests
        run: npm run test:e2e -- --grep "@smoke"
        env:
          TEST_ENV: staging

  # Stage 4: Production smoke (post-deploy, non-blocking)
  production-smoke:
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    continue-on-error: true # Don't fail deployment if smoke tests fail
    steps:
      - uses: actions/checkout@v4
      - name: Run production smoke tests
        run: npm run test:e2e:prod -- --grep "@smoke.*@p0"
        env:
          TEST_ENV: production

      - name: Alert on failure
        if: failure()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          text: '🚨 Production smoke tests failed!'
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

Selection strategy documentation:

# Test Selection Strategy

## Test Promotion Stages

| Stage      | Tests Run           | Time Budget | Blocks Deploy | Failure Action |
| ---------- | ------------------- | ----------- | ------------- | -------------- |
| Pre-Commit | Smoke (@smoke)      | 2 min       | ✅ Yes        | Block commit   |
| CI PR      | Changed + P0-P1     | 10 min      | ✅ Yes        | Block merge    |
| CI Merge   | Full regression     | 30 min      | ✅ Yes        | Block deploy   |
| Staging    | E2E smoke           | 15 min      | ✅ Yes        | Rollback       |
| Production | Critical smoke only | 5 min       | ❌ No         | Alert team     |

## When Full Regression Runs

Full regression suite (`npm run test:regression`) runs in these scenarios:

- ✅ Before merging to `main` (CI Merge stage)
- ✅ Nightly builds (scheduled workflow)
- ✅ Manual trigger (workflow_dispatch)
- ✅ Release candidate testing

Full regression does NOT run on:

- ❌ Every PR commit (too slow)
- ❌ Pre-commit hooks (too slow)
- ❌ Production deployments (deploy-blocking)

## Override Scenarios

Skip tests (emergency only):

```bash
git commit --no-verify  # Skip pre-commit hook
gh pr merge --admin     # Force merge (requires admin)
```

**Key Points**:
- **Progressive validation**: More tests at each stage
- **Time budgets**: Clear expectations per stage
- **Blocking vs. alerting**: Production tests don't block deploy
- **Documentation**: Team knows when full regression runs
- **Emergency overrides**: Documented but discouraged

---

## Test Selection Strategy Checklist

Before implementing selective testing, verify:

- [ ] **Tag strategy defined**: @smoke, @p0-p3, @regression documented
- [ ] **Time budgets set**: Each stage has clear timeout (smoke < 5 min, full < 30 min)
- [ ] **Changed file mapping**: Code changes → test selection logic implemented
- [ ] **Promotion rules documented**: README explains when full regression runs
- [ ] **CI integration**: GitHub Actions uses selective strategy
- [ ] **Local parity**: Developers can run same selections locally
- [ ] **Emergency overrides**: Skip mechanisms documented (--no-verify, admin merge)
- [ ] **Metrics tracked**: Monitor test execution time and selection accuracy

## Integration Points

- Used in workflows: `*ci` (CI/CD setup), `*automate` (test generation with tags)
- Related fragments: `ci-burn-in.md`, `test-priorities-matrix.md`, `test-quality.md`
- Selection tools: Playwright --grep, Cypress @cypress/grep, git diff

_Source: 32+ selective testing strategies blog, Murat testing philosophy, SEON CI optimization_