@zigrivers/scaffold 3.16.0 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/content/knowledge/backend/backend-fintech-broker-integration.md +244 -0
- package/content/knowledge/backend/backend-fintech-compliance.md +181 -0
- package/content/knowledge/backend/backend-fintech-data-modeling.md +210 -0
- package/content/knowledge/backend/backend-fintech-ledger.md +226 -0
- package/content/knowledge/backend/backend-fintech-observability.md +151 -0
- package/content/knowledge/backend/backend-fintech-order-lifecycle.md +213 -0
- package/content/knowledge/backend/backend-fintech-risk-management.md +150 -0
- package/content/knowledge/backend/backend-fintech-testing.md +197 -0
- package/content/knowledge/core/automated-review-tooling.md +10 -0
- package/content/knowledge/core/multi-service-api-contracts.md +634 -0
- package/content/knowledge/core/multi-service-architecture.md +492 -0
- package/content/knowledge/core/multi-service-auth.md +706 -0
- package/content/knowledge/core/multi-service-data-ownership.md +539 -0
- package/content/knowledge/core/multi-service-observability.md +545 -0
- package/content/knowledge/core/multi-service-resilience.md +710 -0
- package/content/knowledge/core/multi-service-task-decomposition.md +615 -0
- package/content/knowledge/core/multi-service-testing.md +728 -0
- package/content/methodology/backend-fintech.yml +46 -0
- package/content/methodology/custom-defaults.yml +6 -0
- package/content/methodology/deep.yml +6 -0
- package/content/methodology/multi-service-overlay.yml +103 -0
- package/content/methodology/mvp.yml +6 -0
- package/content/pipeline/architecture/service-ownership-map.md +83 -0
- package/content/pipeline/quality/cross-service-auth.md +96 -0
- package/content/pipeline/quality/cross-service-observability.md +104 -0
- package/content/pipeline/quality/integration-test-plan.md +106 -0
- package/content/pipeline/specification/inter-service-contracts.md +95 -0
- package/dist/cli/commands/adopt.cli-flags.test.js +20 -0
- package/dist/cli/commands/adopt.cli-flags.test.js.map +1 -1
- package/dist/cli/commands/adopt.d.ts.map +1 -1
- package/dist/cli/commands/adopt.js +11 -3
- package/dist/cli/commands/adopt.js.map +1 -1
- package/dist/cli/commands/complete.d.ts +1 -0
- package/dist/cli/commands/complete.d.ts.map +1 -1
- package/dist/cli/commands/complete.js +26 -8
- package/dist/cli/commands/complete.js.map +1 -1
- package/dist/cli/commands/dashboard.d.ts +1 -0
- package/dist/cli/commands/dashboard.d.ts.map +1 -1
- package/dist/cli/commands/dashboard.js +19 -6
- package/dist/cli/commands/dashboard.js.map +1 -1
- package/dist/cli/commands/decisions.d.ts +1 -0
- package/dist/cli/commands/decisions.d.ts.map +1 -1
- package/dist/cli/commands/decisions.js +18 -4
- package/dist/cli/commands/decisions.js.map +1 -1
- package/dist/cli/commands/info.d.ts +1 -0
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +25 -3
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/init-from.test.d.ts +2 -0
- package/dist/cli/commands/init-from.test.d.ts.map +1 -0
- package/dist/cli/commands/init-from.test.js +315 -0
- package/dist/cli/commands/init-from.test.js.map +1 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +239 -129
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +20 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/cli/commands/next.d.ts +1 -0
- package/dist/cli/commands/next.d.ts.map +1 -1
- package/dist/cli/commands/next.js +40 -4
- package/dist/cli/commands/next.js.map +1 -1
- package/dist/cli/commands/next.test.js +153 -0
- package/dist/cli/commands/next.test.js.map +1 -1
- package/dist/cli/commands/reset.d.ts +1 -0
- package/dist/cli/commands/reset.d.ts.map +1 -1
- package/dist/cli/commands/reset.js +77 -29
- package/dist/cli/commands/reset.js.map +1 -1
- package/dist/cli/commands/rework.d.ts +1 -0
- package/dist/cli/commands/rework.d.ts.map +1 -1
- package/dist/cli/commands/rework.js +16 -2
- package/dist/cli/commands/rework.js.map +1 -1
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +65 -13
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/run.test.js +255 -3
- package/dist/cli/commands/run.test.js.map +1 -1
- package/dist/cli/commands/skip.d.ts +1 -0
- package/dist/cli/commands/skip.d.ts.map +1 -1
- package/dist/cli/commands/skip.js +24 -7
- package/dist/cli/commands/skip.js.map +1 -1
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +51 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/status.test.js +130 -0
- package/dist/cli/commands/status.test.js.map +1 -1
- package/dist/cli/guards-coverage.test.d.ts +2 -0
- package/dist/cli/guards-coverage.test.d.ts.map +1 -0
- package/dist/cli/guards-coverage.test.js +26 -0
- package/dist/cli/guards-coverage.test.js.map +1 -0
- package/dist/cli/guards-integration.test.d.ts +2 -0
- package/dist/cli/guards-integration.test.d.ts.map +1 -0
- package/dist/cli/guards-integration.test.js +178 -0
- package/dist/cli/guards-integration.test.js.map +1 -0
- package/dist/cli/guards.d.ts +13 -0
- package/dist/cli/guards.d.ts.map +1 -0
- package/dist/cli/guards.js +70 -0
- package/dist/cli/guards.js.map +1 -0
- package/dist/cli/guards.test.d.ts +2 -0
- package/dist/cli/guards.test.d.ts.map +1 -0
- package/dist/cli/guards.test.js +136 -0
- package/dist/cli/guards.test.js.map +1 -0
- package/dist/cli/init-flag-families.d.ts +1 -1
- package/dist/cli/init-flag-families.d.ts.map +1 -1
- package/dist/cli/init-flag-families.js +4 -1
- package/dist/cli/init-flag-families.js.map +1 -1
- package/dist/cli/init-flag-families.test.js +10 -0
- package/dist/cli/init-flag-families.test.js.map +1 -1
- package/dist/cli/shutdown.d.ts +2 -3
- package/dist/cli/shutdown.d.ts.map +1 -1
- package/dist/cli/shutdown.js +14 -11
- package/dist/cli/shutdown.js.map +1 -1
- package/dist/cli/shutdown.test.js +2 -4
- package/dist/cli/shutdown.test.js.map +1 -1
- package/dist/config/schema.d.ts +12122 -288
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +74 -79
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +230 -1
- package/dist/config/schema.test.js.map +1 -1
- package/dist/config/validators/backend.d.ts +4 -0
- package/dist/config/validators/backend.d.ts.map +1 -0
- package/dist/config/validators/backend.js +14 -0
- package/dist/config/validators/backend.js.map +1 -0
- package/dist/config/validators/browser-extension.d.ts +4 -0
- package/dist/config/validators/browser-extension.d.ts.map +1 -0
- package/dist/config/validators/browser-extension.js +24 -0
- package/dist/config/validators/browser-extension.js.map +1 -0
- package/dist/config/validators/cli.d.ts +4 -0
- package/dist/config/validators/cli.d.ts.map +1 -0
- package/dist/config/validators/cli.js +14 -0
- package/dist/config/validators/cli.js.map +1 -0
- package/dist/config/validators/data-pipeline.d.ts +4 -0
- package/dist/config/validators/data-pipeline.d.ts.map +1 -0
- package/dist/config/validators/data-pipeline.js +14 -0
- package/dist/config/validators/data-pipeline.js.map +1 -0
- package/dist/config/validators/game.d.ts +4 -0
- package/dist/config/validators/game.d.ts.map +1 -0
- package/dist/config/validators/game.js +14 -0
- package/dist/config/validators/game.js.map +1 -0
- package/dist/config/validators/index.d.ts +7 -0
- package/dist/config/validators/index.d.ts.map +1 -0
- package/dist/config/validators/index.js +27 -0
- package/dist/config/validators/index.js.map +1 -0
- package/dist/config/validators/library.d.ts +4 -0
- package/dist/config/validators/library.d.ts.map +1 -0
- package/dist/config/validators/library.js +25 -0
- package/dist/config/validators/library.js.map +1 -0
- package/dist/config/validators/ml.d.ts +4 -0
- package/dist/config/validators/ml.d.ts.map +1 -0
- package/dist/config/validators/ml.js +31 -0
- package/dist/config/validators/ml.js.map +1 -0
- package/dist/config/validators/mobile-app.d.ts +4 -0
- package/dist/config/validators/mobile-app.d.ts.map +1 -0
- package/dist/config/validators/mobile-app.js +14 -0
- package/dist/config/validators/mobile-app.js.map +1 -0
- package/dist/config/validators/registry.test.d.ts +2 -0
- package/dist/config/validators/registry.test.d.ts.map +1 -0
- package/dist/config/validators/registry.test.js +26 -0
- package/dist/config/validators/registry.test.js.map +1 -0
- package/dist/config/validators/research.d.ts +4 -0
- package/dist/config/validators/research.d.ts.map +1 -0
- package/dist/config/validators/research.js +24 -0
- package/dist/config/validators/research.js.map +1 -0
- package/dist/config/validators/research.test.d.ts +2 -0
- package/dist/config/validators/research.test.d.ts.map +1 -0
- package/dist/config/validators/research.test.js +44 -0
- package/dist/config/validators/research.test.js.map +1 -0
- package/dist/config/validators/types.d.ts +19 -0
- package/dist/config/validators/types.d.ts.map +1 -0
- package/dist/config/validators/types.js +2 -0
- package/dist/config/validators/types.js.map +1 -0
- package/dist/config/validators/validators.test.d.ts +2 -0
- package/dist/config/validators/validators.test.d.ts.map +1 -0
- package/dist/config/validators/validators.test.js +25 -0
- package/dist/config/validators/validators.test.js.map +1 -0
- package/dist/config/validators/web-app.d.ts +4 -0
- package/dist/config/validators/web-app.d.ts.map +1 -0
- package/dist/config/validators/web-app.js +31 -0
- package/dist/config/validators/web-app.js.map +1 -0
- package/dist/core/assembly/context-gatherer.d.ts.map +1 -1
- package/dist/core/assembly/context-gatherer.js +4 -2
- package/dist/core/assembly/context-gatherer.js.map +1 -1
- package/dist/core/assembly/cross-reads.d.ts +61 -0
- package/dist/core/assembly/cross-reads.d.ts.map +1 -0
- package/dist/core/assembly/cross-reads.js +190 -0
- package/dist/core/assembly/cross-reads.js.map +1 -0
- package/dist/core/assembly/cross-reads.test.d.ts +2 -0
- package/dist/core/assembly/cross-reads.test.d.ts.map +1 -0
- package/dist/core/assembly/cross-reads.test.js +497 -0
- package/dist/core/assembly/cross-reads.test.js.map +1 -0
- package/dist/core/assembly/overlay-loader-structural.test.d.ts +2 -0
- package/dist/core/assembly/overlay-loader-structural.test.d.ts.map +1 -0
- package/dist/core/assembly/overlay-loader-structural.test.js +173 -0
- package/dist/core/assembly/overlay-loader-structural.test.js.map +1 -0
- package/dist/core/assembly/overlay-loader.d.ts +19 -3
- package/dist/core/assembly/overlay-loader.d.ts.map +1 -1
- package/dist/core/assembly/overlay-loader.js +135 -4
- package/dist/core/assembly/overlay-loader.js.map +1 -1
- package/dist/core/assembly/overlay-loader.test.js +204 -1
- package/dist/core/assembly/overlay-loader.test.js.map +1 -1
- package/dist/core/assembly/overlay-resolver.d.ts +9 -2
- package/dist/core/assembly/overlay-resolver.d.ts.map +1 -1
- package/dist/core/assembly/overlay-resolver.js +32 -1
- package/dist/core/assembly/overlay-resolver.js.map +1 -1
- package/dist/core/assembly/overlay-resolver.test.js +135 -17
- package/dist/core/assembly/overlay-resolver.test.js.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.d.ts +9 -0
- package/dist/core/assembly/overlay-state-resolver.d.ts.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.js +43 -2
- package/dist/core/assembly/overlay-state-resolver.js.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.test.js +321 -0
- package/dist/core/assembly/overlay-state-resolver.test.js.map +1 -1
- package/dist/core/assembly/update-mode.d.ts +1 -0
- package/dist/core/assembly/update-mode.d.ts.map +1 -1
- package/dist/core/assembly/update-mode.js +17 -9
- package/dist/core/assembly/update-mode.js.map +1 -1
- package/dist/core/dependency/eligibility.d.ts +10 -1
- package/dist/core/dependency/eligibility.d.ts.map +1 -1
- package/dist/core/dependency/eligibility.js +19 -1
- package/dist/core/dependency/eligibility.js.map +1 -1
- package/dist/core/dependency/eligibility.test.js +82 -0
- package/dist/core/dependency/eligibility.test.js.map +1 -1
- package/dist/core/dependency/graph.d.ts +4 -1
- package/dist/core/dependency/graph.d.ts.map +1 -1
- package/dist/core/dependency/graph.js +7 -1
- package/dist/core/dependency/graph.js.map +1 -1
- package/dist/core/dependency/graph.test.js +48 -0
- package/dist/core/dependency/graph.test.js.map +1 -1
- package/dist/core/pipeline/global-steps.d.ts +7 -0
- package/dist/core/pipeline/global-steps.d.ts.map +1 -0
- package/dist/core/pipeline/global-steps.js +18 -0
- package/dist/core/pipeline/global-steps.js.map +1 -0
- package/dist/core/pipeline/resolver.d.ts +1 -0
- package/dist/core/pipeline/resolver.d.ts.map +1 -1
- package/dist/core/pipeline/resolver.js +54 -7
- package/dist/core/pipeline/resolver.js.map +1 -1
- package/dist/core/pipeline/resolver.test.js +51 -1
- package/dist/core/pipeline/resolver.test.js.map +1 -1
- package/dist/core/pipeline/types.d.ts +5 -1
- package/dist/core/pipeline/types.d.ts.map +1 -1
- package/dist/e2e/cross-service-references.test.d.ts +22 -0
- package/dist/e2e/cross-service-references.test.d.ts.map +1 -0
- package/dist/e2e/cross-service-references.test.js +230 -0
- package/dist/e2e/cross-service-references.test.js.map +1 -0
- package/dist/e2e/multi-service-pipeline.test.d.ts +10 -0
- package/dist/e2e/multi-service-pipeline.test.d.ts.map +1 -0
- package/dist/e2e/multi-service-pipeline.test.js +185 -0
- package/dist/e2e/multi-service-pipeline.test.js.map +1 -0
- package/dist/e2e/project-type-overlays.test.js +68 -0
- package/dist/e2e/project-type-overlays.test.js.map +1 -1
- package/dist/e2e/service-execution.test.d.ts +15 -0
- package/dist/e2e/service-execution.test.d.ts.map +1 -0
- package/dist/e2e/service-execution.test.js +219 -0
- package/dist/e2e/service-execution.test.js.map +1 -0
- package/dist/e2e/service-manifest.test.d.ts +19 -0
- package/dist/e2e/service-manifest.test.d.ts.map +1 -0
- package/dist/e2e/service-manifest.test.js +166 -0
- package/dist/e2e/service-manifest.test.js.map +1 -0
- package/dist/project/__frozen-schemas__/schema-v3.9.2.d.ts +224 -224
- package/dist/project/frontmatter.d.ts.map +1 -1
- package/dist/project/frontmatter.js +11 -0
- package/dist/project/frontmatter.js.map +1 -1
- package/dist/project/frontmatter.test.js +71 -0
- package/dist/project/frontmatter.test.js.map +1 -1
- package/dist/state/completion.d.ts +1 -1
- package/dist/state/completion.d.ts.map +1 -1
- package/dist/state/completion.js +10 -8
- package/dist/state/completion.js.map +1 -1
- package/dist/state/decision-logger.d.ts +3 -2
- package/dist/state/decision-logger.d.ts.map +1 -1
- package/dist/state/decision-logger.js +12 -11
- package/dist/state/decision-logger.js.map +1 -1
- package/dist/state/ensure-v3-migration.d.ts +9 -0
- package/dist/state/ensure-v3-migration.d.ts.map +1 -0
- package/dist/state/ensure-v3-migration.js +35 -0
- package/dist/state/ensure-v3-migration.js.map +1 -0
- package/dist/state/lock-manager.d.ts +5 -4
- package/dist/state/lock-manager.d.ts.map +1 -1
- package/dist/state/lock-manager.js +11 -11
- package/dist/state/lock-manager.js.map +1 -1
- package/dist/state/rework-manager.d.ts +1 -2
- package/dist/state/rework-manager.d.ts.map +1 -1
- package/dist/state/rework-manager.js +4 -5
- package/dist/state/rework-manager.js.map +1 -1
- package/dist/state/state-manager.d.ts +25 -1
- package/dist/state/state-manager.d.ts.map +1 -1
- package/dist/state/state-manager.js +86 -12
- package/dist/state/state-manager.js.map +1 -1
- package/dist/state/state-manager.test.js +278 -0
- package/dist/state/state-manager.test.js.map +1 -1
- package/dist/state/state-migration-v3.d.ts +22 -0
- package/dist/state/state-migration-v3.d.ts.map +1 -0
- package/dist/state/state-migration-v3.js +82 -0
- package/dist/state/state-migration-v3.js.map +1 -0
- package/dist/state/state-migration-v3.test.d.ts +2 -0
- package/dist/state/state-migration-v3.test.d.ts.map +1 -0
- package/dist/state/state-migration-v3.test.js +196 -0
- package/dist/state/state-migration-v3.test.js.map +1 -0
- package/dist/state/state-migration.d.ts.map +1 -1
- package/dist/state/state-migration.js +11 -6
- package/dist/state/state-migration.js.map +1 -1
- package/dist/state/state-migration.test.js +47 -2
- package/dist/state/state-migration.test.js.map +1 -1
- package/dist/state/state-path-resolver.d.ts +23 -0
- package/dist/state/state-path-resolver.d.ts.map +1 -0
- package/dist/state/state-path-resolver.js +36 -0
- package/dist/state/state-path-resolver.js.map +1 -0
- package/dist/state/state-path-resolver.test.d.ts +2 -0
- package/dist/state/state-path-resolver.test.d.ts.map +1 -0
- package/dist/state/state-path-resolver.test.js +78 -0
- package/dist/state/state-path-resolver.test.js.map +1 -0
- package/dist/state/state-version-dispatch.d.ts +17 -0
- package/dist/state/state-version-dispatch.d.ts.map +1 -0
- package/dist/state/state-version-dispatch.js +27 -0
- package/dist/state/state-version-dispatch.js.map +1 -0
- package/dist/state/state-version-dispatch.test.d.ts +2 -0
- package/dist/state/state-version-dispatch.test.d.ts.map +1 -0
- package/dist/state/state-version-dispatch.test.js +40 -0
- package/dist/state/state-version-dispatch.test.js.map +1 -0
- package/dist/types/config.d.ts +33 -3
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.test.js +62 -1
- package/dist/types/config.test.js.map +1 -1
- package/dist/types/dependency.d.ts +9 -0
- package/dist/types/dependency.d.ts.map +1 -1
- package/dist/types/frontmatter.d.ts +5 -0
- package/dist/types/frontmatter.d.ts.map +1 -1
- package/dist/types/lock.d.ts +1 -1
- package/dist/types/lock.d.ts.map +1 -1
- package/dist/types/state.d.ts +1 -1
- package/dist/types/state.d.ts.map +1 -1
- package/dist/utils/artifact-path.d.ts +19 -0
- package/dist/utils/artifact-path.d.ts.map +1 -0
- package/dist/utils/artifact-path.js +95 -0
- package/dist/utils/artifact-path.js.map +1 -0
- package/dist/utils/artifact-path.test.d.ts +2 -0
- package/dist/utils/artifact-path.test.d.ts.map +1 -0
- package/dist/utils/artifact-path.test.js +138 -0
- package/dist/utils/artifact-path.test.js.map +1 -0
- package/dist/utils/errors.d.ts +3 -1
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +21 -2
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/errors.test.js +27 -1
- package/dist/utils/errors.test.js.map +1 -1
- package/dist/utils/user-errors.d.ts +46 -0
- package/dist/utils/user-errors.d.ts.map +1 -0
- package/dist/utils/user-errors.js +76 -0
- package/dist/utils/user-errors.js.map +1 -0
- package/dist/utils/user-errors.test.d.ts +2 -0
- package/dist/utils/user-errors.test.d.ts.map +1 -0
- package/dist/utils/user-errors.test.js +74 -0
- package/dist/utils/user-errors.test.js.map +1 -0
- package/dist/validation/index.d.ts.map +1 -1
- package/dist/validation/index.js +16 -0
- package/dist/validation/index.js.map +1 -1
- package/dist/validation/index.test.js +48 -0
- package/dist/validation/index.test.js.map +1 -1
- package/dist/validation/state-validator.d.ts +5 -2
- package/dist/validation/state-validator.d.ts.map +1 -1
- package/dist/validation/state-validator.js +18 -20
- package/dist/validation/state-validator.js.map +1 -1
- package/dist/validation/state-validator.test.js +31 -2
- package/dist/validation/state-validator.test.js.map +1 -1
- package/dist/wizard/copy/backend.d.ts.map +1 -1
- package/dist/wizard/copy/backend.js +12 -0
- package/dist/wizard/copy/backend.js.map +1 -1
- package/dist/wizard/flags.d.ts +1 -0
- package/dist/wizard/flags.d.ts.map +1 -1
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +5 -1
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +45 -2
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts +23 -0
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +85 -47
- package/dist/wizard/wizard.js.map +1 -1
- package/dist/wizard/wizard.test.js +186 -1
- package/dist/wizard/wizard.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: multi-service-testing
|
|
3
|
+
description: Consumer-driven contract testing, cross-service E2E strategies, and service test doubles
|
|
4
|
+
topics: [contract-tests, pact, schema-registry, cross-service-e2e, test-doubles]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Testing a multi-service system requires a different strategy than testing a monolith. The test pyramid still applies, but each layer has multi-service-specific concerns.
|
|
10
|
+
|
|
11
|
+
**The multi-service test pyramid** (bottom to top):
|
|
12
|
+
- **Unit tests:** Business logic within a single service. No I/O, no other services.
|
|
13
|
+
- **Integration tests:** A service with its own database, cache, and internal queue. Real infrastructure, no other services.
|
|
14
|
+
- **Contract tests:** Verify inter-service API agreements. Consumers define expectations; providers verify them in CI. Fast, per-service, no full-system setup required.
|
|
15
|
+
- **Cross-service E2E:** Full user journeys against a real multi-service environment. Highest confidence, highest maintenance cost. Use sparingly (5-15 tests for critical journeys).
|
|
16
|
+
|
|
17
|
+
**Consumer-driven contract testing with Pact:** Consumers write tests that define expected interactions (request + response shape). These generate `.json` pact files published to a Pact Broker. Providers verify all consumer contracts in their CI pipeline. A breaking provider change fails the provider's CI before deployment. The `can-i-deploy` gate prevents incompatible versions from reaching production.
|
|
18
|
+
|
|
19
|
+
**Async contracts via schema registry:** For Kafka events, Avro schemas registered in a schema registry enforce compatibility at registration time (`BACKWARD`, `FORWARD`, or `FULL` compatibility modes). Breaking schema changes are rejected before any message is produced.
|
|
20
|
+
|
|
21
|
+
**Service test doubles:**
|
|
22
|
+
- **WireMock:** Configurable HTTP stub servers for integration tests.
|
|
23
|
+
- **In-memory fakes:** Stateful service simulators with rich assertion APIs.
|
|
24
|
+
- **Full service in Docker:** Real service with isolated test database, used in E2E tests.
|
|
25
|
+
|
|
26
|
+
## Deep Guidance
|
|
27
|
+
|
|
28
|
+
## The Multi-Service Test Pyramid
|
|
29
|
+
|
|
30
|
+
The standard test pyramid (unit, integration, E2E) extends naturally to multi-service systems with contract tests inserted between integration and E2E:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
/ Cross-Service E2E \ Few (5-15), slow, validates real user journeys
|
|
34
|
+
/ Contract Tests \ Moderate, fast, validates service API agreements
|
|
35
|
+
/ Integration Tests \ Per-service, medium speed, validates service internals
|
|
36
|
+
/ Unit Tests \ Many, fast, tests pure business logic in isolation
|
|
37
|
+
________________________________
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Layer responsibilities:**
|
|
41
|
+
- **Unit tests:** Business logic within a single service. No I/O, no other services, milliseconds.
|
|
42
|
+
- **Integration tests:** A service interacting with its own database, cache, and internal message queue. Real infrastructure, no other services.
|
|
43
|
+
- **Contract tests:** Verify that a service's API matches what its consumers expect, and that consumers correctly call the provider's API. Run per-service, not as a full-system test.
|
|
44
|
+
- **Cross-service E2E:** Full user journeys against a real or realistic multi-service environment. Highest confidence, highest maintenance cost.
|
|
45
|
+
|
|
46
|
+
## Consumer-Driven Contract Testing with Pact
|
|
47
|
+
|
|
48
|
+
### What Contract Testing Solves
|
|
49
|
+
|
|
50
|
+
Without contract tests, breaking changes in a provider's API are discovered when consumers deploy — in staging or, worse, production. Contract tests move that discovery to CI, in the provider's build, before the breaking change is merged.
|
|
51
|
+
|
|
52
|
+
Consumer-driven contract testing inverts the usual testing relationship: consumers define their expectations of the provider; the provider verifies it satisfies all consumer contracts. This means:
|
|
53
|
+
|
|
54
|
+
- Consumers own the contract specification.
|
|
55
|
+
- Providers run consumer contracts as part of their CI pipeline.
|
|
56
|
+
- A breaking change in the provider fails the provider's CI before deployment.
|
|
57
|
+
|
|
58
|
+
### Pact: Consumer Side
|
|
59
|
+
|
|
60
|
+
The consumer writes a Pact test that defines the expected interaction with the provider. Pact records the interaction and generates a `.json` pact file.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// tests/contracts/order-service.pact.test.ts (consumer: api-gateway)
|
|
64
|
+
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
|
|
65
|
+
import path from 'path'
|
|
66
|
+
import { OrderServiceClient } from '../../src/clients/order-service.js'
|
|
67
|
+
|
|
68
|
+
const { like, string, integer, eachLike } = MatchersV3
|
|
69
|
+
|
|
70
|
+
const provider = new PactV3({
|
|
71
|
+
consumer: 'api-gateway',
|
|
72
|
+
provider: 'order-service',
|
|
73
|
+
dir: path.join(__dirname, '../../pacts'),
|
|
74
|
+
logLevel: 'warn',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('api-gateway → order-service contract', () => {
|
|
78
|
+
describe('GET /orders/:id', () => {
|
|
79
|
+
it('returns order details for a valid order ID', async () => {
|
|
80
|
+
await provider
|
|
81
|
+
.given('order 550e8400 exists and is confirmed')
|
|
82
|
+
.uponReceiving('a request for order 550e8400')
|
|
83
|
+
.withRequest({
|
|
84
|
+
method: 'GET',
|
|
85
|
+
path: '/orders/550e8400-e29b-41d4-a716-446655440000',
|
|
86
|
+
headers: { Authorization: like('Bearer token') },
|
|
87
|
+
})
|
|
88
|
+
.willRespondWith({
|
|
89
|
+
status: 200,
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: {
|
|
92
|
+
orderId: string('550e8400-e29b-41d4-a716-446655440000'),
|
|
93
|
+
status: string('confirmed'),
|
|
94
|
+
items: eachLike({
|
|
95
|
+
productId: string('prod-123'),
|
|
96
|
+
quantity: integer(2),
|
|
97
|
+
unitPriceCents: integer(1999),
|
|
98
|
+
}),
|
|
99
|
+
totalCents: integer(3998),
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
.executeTest(async (mockServer) => {
|
|
103
|
+
const client = new OrderServiceClient({ baseUrl: mockServer.url })
|
|
104
|
+
const order = await client.getOrder('550e8400-e29b-41d4-a716-446655440000')
|
|
105
|
+
expect(order.status).toBe('confirmed')
|
|
106
|
+
expect(order.items).toHaveLength(1)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('POST /orders', () => {
|
|
112
|
+
it('places a new order and returns order ID', async () => {
|
|
113
|
+
await provider
|
|
114
|
+
.given('user cust-001 exists and inventory is available')
|
|
115
|
+
.uponReceiving('a request to place an order')
|
|
116
|
+
.withRequest({
|
|
117
|
+
method: 'POST',
|
|
118
|
+
path: '/orders',
|
|
119
|
+
headers: {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
Authorization: like('Bearer token'),
|
|
122
|
+
},
|
|
123
|
+
body: {
|
|
124
|
+
customerId: string('cust-001'),
|
|
125
|
+
items: eachLike({ productId: string('prod-123'), quantity: integer(1) }),
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
.willRespondWith({
|
|
129
|
+
status: 201,
|
|
130
|
+
body: {
|
|
131
|
+
orderId: string('new-order-uuid'),
|
|
132
|
+
status: string('pending'),
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
.executeTest(async (mockServer) => {
|
|
136
|
+
const client = new OrderServiceClient({ baseUrl: mockServer.url })
|
|
137
|
+
const result = await client.placeOrder({
|
|
138
|
+
customerId: 'cust-001',
|
|
139
|
+
items: [{ productId: 'prod-123', quantity: 1 }],
|
|
140
|
+
})
|
|
141
|
+
expect(result.status).toBe('pending')
|
|
142
|
+
expect(result.orderId).toBeTruthy()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Running the consumer test generates `pacts/api-gateway-order-service.json`** — this file is published to the Pact Broker for the provider to verify.
|
|
150
|
+
|
|
151
|
+
### Pact: Provider Side
|
|
152
|
+
|
|
153
|
+
The provider loads consumer pacts from the Pact Broker and verifies them against the running service. Provider states map to database setup functions.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// tests/contracts/verify-pacts.test.ts (provider: order-service)
|
|
157
|
+
import { Verifier } from '@pact-foundation/pact'
|
|
158
|
+
import { app } from '../../src/app.js'
|
|
159
|
+
import { db } from '../../src/db/index.js'
|
|
160
|
+
import type { Server } from 'http'
|
|
161
|
+
|
|
162
|
+
let server: Server
|
|
163
|
+
|
|
164
|
+
beforeAll(async () => {
|
|
165
|
+
await db.migrate.latest()
|
|
166
|
+
server = app.listen(0) // random port
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
afterAll(async () => {
|
|
170
|
+
await new Promise((resolve) => server.close(resolve))
|
|
171
|
+
await db.destroy()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('Pact provider verification: order-service', () => {
|
|
175
|
+
it('satisfies all consumer contracts', async () => {
|
|
176
|
+
const opts = {
|
|
177
|
+
provider: 'order-service',
|
|
178
|
+
providerBaseUrl: `http://localhost:${(server.address() as { port: number }).port}`,
|
|
179
|
+
|
|
180
|
+
// Fetch pacts from broker
|
|
181
|
+
pactBrokerUrl: process.env.PACT_BROKER_URL ?? 'http://pact-broker:9292',
|
|
182
|
+
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
|
|
183
|
+
publishVerificationResult: process.env.CI === 'true',
|
|
184
|
+
providerVersion: process.env.GIT_SHA ?? 'local',
|
|
185
|
+
|
|
186
|
+
// Provider state handlers — set up database state for each interaction
|
|
187
|
+
stateHandlers: {
|
|
188
|
+
'order 550e8400 exists and is confirmed': async () => {
|
|
189
|
+
await db('orders').insert({
|
|
190
|
+
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
191
|
+
customer_id: 'cust-001',
|
|
192
|
+
status: 'confirmed',
|
|
193
|
+
})
|
|
194
|
+
await db('order_items').insert({
|
|
195
|
+
order_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
196
|
+
product_id: 'prod-123',
|
|
197
|
+
quantity: 2,
|
|
198
|
+
unit_price_cents: 1999,
|
|
199
|
+
})
|
|
200
|
+
},
|
|
201
|
+
'user cust-001 exists and inventory is available': async () => {
|
|
202
|
+
await db('customers').insert({ id: 'cust-001', email: 'test@example.com' })
|
|
203
|
+
await db('inventory').insert({ product_id: 'prod-123', available_units: 100 })
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// Teardown between states
|
|
208
|
+
beforeEach: async () => {
|
|
209
|
+
await db.raw('TRUNCATE orders, order_items, customers, inventory RESTART IDENTITY CASCADE')
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
logLevel: 'warn',
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await new Verifier(opts).verifyProvider()
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Trade-offs (Pact):**
|
|
221
|
+
- (+) Breaking changes in the provider are detected in the provider's CI before the change is deployed.
|
|
222
|
+
- (+) Consumers define exactly what they use — providers can safely change anything not referenced in contracts.
|
|
223
|
+
- (+) The Pact Broker provides a dependency graph: which consumers use which provider endpoints.
|
|
224
|
+
- (-) Pact tests require maintaining provider state handlers — a setup burden that grows with the number of interactions.
|
|
225
|
+
- (-) Pact tests are not a substitute for integration tests. They verify the contract format, not business logic.
|
|
226
|
+
- (-) Requires a Pact Broker for CI integration. Self-hosting adds operational overhead (PactFlow offers hosted option).
|
|
227
|
+
|
|
228
|
+
### Pact Broker Integration in CI
|
|
229
|
+
|
|
230
|
+
```yaml
|
|
231
|
+
# .github/workflows/consumer-contract-test.yml
|
|
232
|
+
name: Consumer Contract Tests
|
|
233
|
+
on: [push, pull_request]
|
|
234
|
+
|
|
235
|
+
jobs:
|
|
236
|
+
contract-tests:
|
|
237
|
+
runs-on: ubuntu-latest
|
|
238
|
+
steps:
|
|
239
|
+
- uses: actions/checkout@v4
|
|
240
|
+
|
|
241
|
+
- name: Run consumer contract tests
|
|
242
|
+
run: npm run test:contract:consumer
|
|
243
|
+
env:
|
|
244
|
+
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
|
|
245
|
+
|
|
246
|
+
- name: Publish pacts to broker
|
|
247
|
+
run: |
|
|
248
|
+
npx pact-broker publish \
|
|
249
|
+
--pact-files-or-dirs pacts/ \
|
|
250
|
+
--consumer-app-version ${{ github.sha }} \
|
|
251
|
+
--branch ${{ github.ref_name }} \
|
|
252
|
+
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
|
|
253
|
+
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
# .github/workflows/provider-contract-test.yml
|
|
257
|
+
name: Provider Contract Verification
|
|
258
|
+
on: [push, pull_request]
|
|
259
|
+
|
|
260
|
+
jobs:
|
|
261
|
+
verify-contracts:
|
|
262
|
+
runs-on: ubuntu-latest
|
|
263
|
+
services:
|
|
264
|
+
postgres:
|
|
265
|
+
image: postgres:16
|
|
266
|
+
env:
|
|
267
|
+
POSTGRES_DB: order_service_test
|
|
268
|
+
POSTGRES_USER: test
|
|
269
|
+
POSTGRES_PASSWORD: test
|
|
270
|
+
ports: ['5432:5432']
|
|
271
|
+
steps:
|
|
272
|
+
- uses: actions/checkout@v4
|
|
273
|
+
|
|
274
|
+
- name: Verify consumer contracts
|
|
275
|
+
run: npm run test:contract:provider
|
|
276
|
+
env:
|
|
277
|
+
DATABASE_URL: postgres://test:test@localhost:5432/order_service_test
|
|
278
|
+
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
|
|
279
|
+
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
|
|
280
|
+
GIT_SHA: ${{ github.sha }}
|
|
281
|
+
CI: true
|
|
282
|
+
|
|
283
|
+
- name: Can-I-Deploy check
|
|
284
|
+
run: |
|
|
285
|
+
npx pact-broker can-i-deploy \
|
|
286
|
+
--pacticipant order-service \
|
|
287
|
+
--version ${{ github.sha }} \
|
|
288
|
+
--to-environment production \
|
|
289
|
+
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
|
|
290
|
+
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Schema Registry Approach
|
|
294
|
+
|
|
295
|
+
For event-driven systems using Kafka, Avro schemas registered in a schema registry replace Pact for async contract testing.
|
|
296
|
+
|
|
297
|
+
**Schema registration (producer/provider side):**
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// src/events/order-placed.schema.ts
|
|
301
|
+
import { SchemaRegistry } from '@kafkajs/confluent-schema-registry'
|
|
302
|
+
|
|
303
|
+
const registry = new SchemaRegistry({
|
|
304
|
+
host: process.env.SCHEMA_REGISTRY_URL ?? 'http://schema-registry:8081',
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
export const ORDER_PLACED_SUBJECT = 'order.placed-value'
|
|
308
|
+
|
|
309
|
+
export const orderPlacedSchema = {
|
|
310
|
+
type: 'record' as const,
|
|
311
|
+
name: 'OrderPlaced',
|
|
312
|
+
namespace: 'com.example.orders',
|
|
313
|
+
fields: [
|
|
314
|
+
{ name: 'orderId', type: 'string' },
|
|
315
|
+
{ name: 'customerId', type: 'string' },
|
|
316
|
+
{ name: 'totalCents', type: 'int' },
|
|
317
|
+
{ name: 'placedAt', type: 'string' }, // ISO 8601
|
|
318
|
+
{
|
|
319
|
+
name: 'items',
|
|
320
|
+
type: {
|
|
321
|
+
type: 'array',
|
|
322
|
+
items: {
|
|
323
|
+
type: 'record',
|
|
324
|
+
name: 'OrderItem',
|
|
325
|
+
fields: [
|
|
326
|
+
{ name: 'productId', type: 'string' },
|
|
327
|
+
{ name: 'quantity', type: 'int' },
|
|
328
|
+
{ name: 'unitPriceCents', type: 'int' },
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function registerOrderPlacedSchema(): Promise<number> {
|
|
337
|
+
const { id } = await registry.register(
|
|
338
|
+
{ type: 'AVRO', schema: JSON.stringify(orderPlacedSchema) },
|
|
339
|
+
{ subject: ORDER_PLACED_SUBJECT }
|
|
340
|
+
)
|
|
341
|
+
return id
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Schema compatibility modes:**
|
|
346
|
+
- `BACKWARD`: new schema can read data written with the old schema. Add fields with defaults, remove optional fields. Safe for consumers to upgrade first.
|
|
347
|
+
- `FORWARD`: old schema can read data written with the new schema. Safe for producers to upgrade first.
|
|
348
|
+
- `FULL`: both backward and forward compatible. Strictest, safest for large consumer bases.
|
|
349
|
+
|
|
350
|
+
**Trade-offs (schema registry vs. Pact for async):**
|
|
351
|
+
- (+) Schema compatibility checks run at schema registration time — breaking changes are rejected before any message is produced.
|
|
352
|
+
- (+) Every consumer automatically validates incoming messages against the registered schema. No additional test setup.
|
|
353
|
+
- (-) Schema registry only validates structure, not behavior. Business logic changes (field semantics, value ranges) are not caught.
|
|
354
|
+
- (-) Schema registry is a shared dependency. If it is unavailable, schema validation fails. Cache schemas locally for resilience.
|
|
355
|
+
|
|
356
|
+
## Service Test Doubles
|
|
357
|
+
|
|
358
|
+
### Test Double Taxonomy for Multi-Service Systems
|
|
359
|
+
|
|
360
|
+
In a multi-service system, test doubles replace entire downstream services, not just individual functions. The appropriate double depends on the test level.
|
|
361
|
+
|
|
362
|
+
| Double Type | Used At | Behavior | State |
|
|
363
|
+
|-------------|---------|----------|-------|
|
|
364
|
+
| Mock server (WireMock, msw) | Integration tests | Configurable stubbed HTTP responses | Stateless |
|
|
365
|
+
| In-memory fake service | Integration tests | Simplified but functionally correct | Stateful |
|
|
366
|
+
| Contract mock (Pact mock server) | Contract tests | Records interactions for contract files | Stateless |
|
|
367
|
+
| Full service in Docker | E2E / acceptance tests | Real service, isolated test database | Stateful |
|
|
368
|
+
|
|
369
|
+
### WireMock for HTTP Service Doubles
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
// tests/integration/order-service.test.ts
|
|
373
|
+
// The order service calls the inventory service and payment service.
|
|
374
|
+
// In integration tests, replace both with WireMock servers.
|
|
375
|
+
import { WireMock } from 'wiremock-captain'
|
|
376
|
+
|
|
377
|
+
describe('OrderService integration', () => {
|
|
378
|
+
let inventoryMock: WireMock
|
|
379
|
+
let paymentMock: WireMock
|
|
380
|
+
|
|
381
|
+
beforeAll(async () => {
|
|
382
|
+
inventoryMock = new WireMock('http://inventory-mock:8080')
|
|
383
|
+
paymentMock = new WireMock('http://payment-mock:8080')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
afterEach(async () => {
|
|
387
|
+
await inventoryMock.clearAll()
|
|
388
|
+
await paymentMock.clearAll()
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('creates an order when inventory is available and payment succeeds', async () => {
|
|
392
|
+
// Stub inventory service
|
|
393
|
+
await inventoryMock.register(
|
|
394
|
+
{ method: 'POST', endpoint: '/reserve' },
|
|
395
|
+
{
|
|
396
|
+
status: 200,
|
|
397
|
+
body: { reservationId: 'res-001', reserved: true },
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
// Stub payment service
|
|
402
|
+
await paymentMock.register(
|
|
403
|
+
{ method: 'POST', endpoint: '/charge' },
|
|
404
|
+
{
|
|
405
|
+
status: 200,
|
|
406
|
+
body: { chargeId: 'chg-001', status: 'succeeded' },
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
const result = await orderService.placeOrder({
|
|
411
|
+
customerId: 'cust-001',
|
|
412
|
+
items: [{ productId: 'prod-123', quantity: 1 }],
|
|
413
|
+
paymentMethodId: 'pm-visa',
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
expect(result.status).toBe('confirmed')
|
|
417
|
+
|
|
418
|
+
// Verify inventory was reserved exactly once
|
|
419
|
+
const inventoryRequests = await inventoryMock.getRequestsForAPI(
|
|
420
|
+
{ method: 'POST', endpoint: '/reserve' }
|
|
421
|
+
)
|
|
422
|
+
expect(inventoryRequests).toHaveLength(1)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('rolls back inventory reservation when payment fails', async () => {
|
|
426
|
+
await inventoryMock.register(
|
|
427
|
+
{ method: 'POST', endpoint: '/reserve' },
|
|
428
|
+
{ status: 200, body: { reservationId: 'res-002', reserved: true } }
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
await inventoryMock.register(
|
|
432
|
+
{ method: 'DELETE', endpoint: '/reserve/res-002' },
|
|
433
|
+
{ status: 204 }
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
await paymentMock.register(
|
|
437
|
+
{ method: 'POST', endpoint: '/charge' },
|
|
438
|
+
{ status: 402, body: { error: 'INSUFFICIENT_FUNDS' } }
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
await expect(
|
|
442
|
+
orderService.placeOrder({
|
|
443
|
+
customerId: 'cust-001',
|
|
444
|
+
items: [{ productId: 'prod-123', quantity: 1 }],
|
|
445
|
+
paymentMethodId: 'pm-declined',
|
|
446
|
+
})
|
|
447
|
+
).rejects.toThrow('Payment failed: INSUFFICIENT_FUNDS')
|
|
448
|
+
|
|
449
|
+
// Verify the reservation was cancelled (rollback executed)
|
|
450
|
+
const cancelRequests = await inventoryMock.getRequestsForAPI(
|
|
451
|
+
{ method: 'DELETE', endpoint: '/reserve/res-002' }
|
|
452
|
+
)
|
|
453
|
+
expect(cancelRequests).toHaveLength(1)
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### In-Memory Fake Services
|
|
459
|
+
|
|
460
|
+
For services where you need stateful behavior in tests (e.g., a notification service that should accumulate sent notifications for later assertion), an in-memory fake is more ergonomic than WireMock.
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
// tests/fakes/fake-notification-service.ts
|
|
464
|
+
import express from 'express'
|
|
465
|
+
import type { Application } from 'express'
|
|
466
|
+
|
|
467
|
+
interface SentNotification {
|
|
468
|
+
to: string
|
|
469
|
+
template: string
|
|
470
|
+
data: Record<string, unknown>
|
|
471
|
+
sentAt: Date
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export class FakeNotificationService {
|
|
475
|
+
private readonly app: Application
|
|
476
|
+
private readonly notifications: SentNotification[] = []
|
|
477
|
+
private server?: ReturnType<Application['listen']>
|
|
478
|
+
|
|
479
|
+
constructor() {
|
|
480
|
+
this.app = express()
|
|
481
|
+
this.app.use(express.json())
|
|
482
|
+
|
|
483
|
+
this.app.post('/notifications', (req, res) => {
|
|
484
|
+
this.notifications.push({
|
|
485
|
+
...req.body,
|
|
486
|
+
sentAt: new Date(),
|
|
487
|
+
})
|
|
488
|
+
res.json({ notificationId: `notif-${Date.now()}`, status: 'queued' })
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async start(port = 0): Promise<number> {
|
|
493
|
+
return new Promise((resolve) => {
|
|
494
|
+
this.server = this.app.listen(port, () => {
|
|
495
|
+
resolve((this.server!.address() as { port: number }).port)
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async stop(): Promise<void> {
|
|
501
|
+
return new Promise((resolve) => this.server?.close(() => resolve()))
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
reset(): void {
|
|
505
|
+
this.notifications.length = 0
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
getSentNotifications(): SentNotification[] {
|
|
509
|
+
return [...this.notifications]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
getNotificationsTo(email: string): SentNotification[] {
|
|
513
|
+
return this.notifications.filter((n) => n.to === email)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
**Trade-offs (in-memory fakes vs. mock servers):**
|
|
519
|
+
- (+) Fakes can maintain state across multiple requests — essential for testing workflows.
|
|
520
|
+
- (+) Assertion API is richer (`.getSentNotifications()`) than polling a mock server's request log.
|
|
521
|
+
- (-) Fakes require maintenance — when the real service's API changes, the fake must be updated.
|
|
522
|
+
- (-) Fakes can diverge from the real service behavior, making integration tests misleading. Mitigate by running contract tests against fakes as well as real services.
|
|
523
|
+
|
|
524
|
+
## Cross-Service E2E Test Design
|
|
525
|
+
|
|
526
|
+
### When to Use Cross-Service E2E Tests
|
|
527
|
+
|
|
528
|
+
Cross-service E2E tests are expensive: they require a running multi-service environment, real or realistic databases, and produce flaky failures unrelated to the code under test (network timeouts, service startup order, database state). Use them sparingly for the scenarios that nothing else can validate.
|
|
529
|
+
|
|
530
|
+
**Use cross-service E2E for:**
|
|
531
|
+
- Critical user journeys that exercise the full service graph (place order, payment, fulfillment, notification)
|
|
532
|
+
- Smoke tests after deployment to verify the environment is healthy
|
|
533
|
+
- Integration scenarios that contract tests cannot cover (e.g., business logic that depends on real data state across services)
|
|
534
|
+
|
|
535
|
+
**Do NOT use cross-service E2E for:**
|
|
536
|
+
- Validation error paths (unit tests)
|
|
537
|
+
- Per-service business logic (integration tests)
|
|
538
|
+
- API contract format verification (contract tests)
|
|
539
|
+
|
|
540
|
+
### E2E Test Environment Strategies
|
|
541
|
+
|
|
542
|
+
| Strategy | Setup Cost | Isolation | CI Suitability |
|
|
543
|
+
|----------|------------|-----------|----------------|
|
|
544
|
+
| Shared staging environment | Low | None | Poor — state bleeds between runs |
|
|
545
|
+
| Per-PR ephemeral environment | High | Full | Good — but slow to provision |
|
|
546
|
+
| Docker Compose local multi-service | Medium | Full | Good — fast for local dev |
|
|
547
|
+
| Kubernetes namespace per-branch | High | Full | Good — realistic but expensive |
|
|
548
|
+
|
|
549
|
+
**Docker Compose for cross-service E2E (recommended for most teams):**
|
|
550
|
+
|
|
551
|
+
```yaml
|
|
552
|
+
# docker-compose.e2e.yml
|
|
553
|
+
version: '3.9'
|
|
554
|
+
|
|
555
|
+
services:
|
|
556
|
+
api-gateway:
|
|
557
|
+
build:
|
|
558
|
+
context: ../../api-gateway
|
|
559
|
+
dockerfile: Dockerfile
|
|
560
|
+
ports: ['3000:3000']
|
|
561
|
+
environment:
|
|
562
|
+
ORDER_SERVICE_URL: http://order-service:8080
|
|
563
|
+
AUTH_SERVICE_URL: http://auth-service:8080
|
|
564
|
+
depends_on:
|
|
565
|
+
order-service:
|
|
566
|
+
condition: service_healthy
|
|
567
|
+
auth-service:
|
|
568
|
+
condition: service_healthy
|
|
569
|
+
|
|
570
|
+
order-service:
|
|
571
|
+
build:
|
|
572
|
+
context: ../../order-service
|
|
573
|
+
dockerfile: Dockerfile
|
|
574
|
+
environment:
|
|
575
|
+
DATABASE_URL: postgres://test:test@order-db:5432/order_test
|
|
576
|
+
INVENTORY_SERVICE_URL: http://inventory-service:8080
|
|
577
|
+
PAYMENT_SERVICE_URL: http://payment-service:8080
|
|
578
|
+
depends_on:
|
|
579
|
+
order-db:
|
|
580
|
+
condition: service_healthy
|
|
581
|
+
healthcheck:
|
|
582
|
+
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health']
|
|
583
|
+
interval: 5s
|
|
584
|
+
timeout: 3s
|
|
585
|
+
retries: 10
|
|
586
|
+
start_period: 10s
|
|
587
|
+
|
|
588
|
+
order-db:
|
|
589
|
+
image: postgres:16-alpine
|
|
590
|
+
environment:
|
|
591
|
+
POSTGRES_DB: order_test
|
|
592
|
+
POSTGRES_USER: test
|
|
593
|
+
POSTGRES_PASSWORD: test
|
|
594
|
+
healthcheck:
|
|
595
|
+
test: ['CMD-SHELL', 'pg_isready -U test -d order_test']
|
|
596
|
+
interval: 2s
|
|
597
|
+
timeout: 2s
|
|
598
|
+
retries: 10
|
|
599
|
+
|
|
600
|
+
auth-service:
|
|
601
|
+
build:
|
|
602
|
+
context: ../../auth-service
|
|
603
|
+
dockerfile: Dockerfile
|
|
604
|
+
environment:
|
|
605
|
+
DATABASE_URL: postgres://test:test@auth-db:5432/auth_test
|
|
606
|
+
JWT_SECRET: test-secret-not-for-production
|
|
607
|
+
depends_on:
|
|
608
|
+
auth-db:
|
|
609
|
+
condition: service_healthy
|
|
610
|
+
healthcheck:
|
|
611
|
+
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health']
|
|
612
|
+
interval: 5s
|
|
613
|
+
retries: 10
|
|
614
|
+
|
|
615
|
+
auth-db:
|
|
616
|
+
image: postgres:16-alpine
|
|
617
|
+
environment:
|
|
618
|
+
POSTGRES_DB: auth_test
|
|
619
|
+
POSTGRES_USER: test
|
|
620
|
+
POSTGRES_PASSWORD: test
|
|
621
|
+
healthcheck:
|
|
622
|
+
test: ['CMD-SHELL', 'pg_isready -U test -d auth_test']
|
|
623
|
+
interval: 2s
|
|
624
|
+
retries: 10
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
**Cross-service E2E test (using the Docker Compose stack):**
|
|
628
|
+
|
|
629
|
+
```typescript
|
|
630
|
+
// tests/e2e/order-placement.e2e.test.ts
|
|
631
|
+
import axios from 'axios'
|
|
632
|
+
|
|
633
|
+
const GATEWAY = 'http://localhost:3000'
|
|
634
|
+
|
|
635
|
+
describe('Order placement journey', () => {
|
|
636
|
+
let authToken: string
|
|
637
|
+
let userId: string
|
|
638
|
+
|
|
639
|
+
beforeAll(async () => {
|
|
640
|
+
// Register and authenticate a test user
|
|
641
|
+
const reg = await axios.post(`${GATEWAY}/api/v1/auth/register`, {
|
|
642
|
+
email: `e2e-${Date.now()}@example.com`,
|
|
643
|
+
password: 'TestPassword123!',
|
|
644
|
+
})
|
|
645
|
+
userId = reg.data.userId
|
|
646
|
+
|
|
647
|
+
const login = await axios.post(`${GATEWAY}/api/v1/auth/login`, {
|
|
648
|
+
email: reg.data.email,
|
|
649
|
+
password: 'TestPassword123!',
|
|
650
|
+
})
|
|
651
|
+
authToken = login.data.token
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('completes the full order placement flow', async () => {
|
|
655
|
+
// Place an order
|
|
656
|
+
const orderRes = await axios.post(
|
|
657
|
+
`${GATEWAY}/api/v1/orders`,
|
|
658
|
+
{
|
|
659
|
+
items: [{ productId: 'test-product-001', quantity: 2 }],
|
|
660
|
+
paymentMethodId: 'test-card-visa',
|
|
661
|
+
},
|
|
662
|
+
{ headers: { Authorization: `Bearer ${authToken}` } }
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
expect(orderRes.status).toBe(201)
|
|
666
|
+
const { orderId } = orderRes.data
|
|
667
|
+
expect(orderId).toBeTruthy()
|
|
668
|
+
|
|
669
|
+
// Poll for order confirmation (async processing)
|
|
670
|
+
let order: { status: string } | null = null
|
|
671
|
+
for (let i = 0; i < 10; i++) {
|
|
672
|
+
const statusRes = await axios.get(`${GATEWAY}/api/v1/orders/${orderId}`, {
|
|
673
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
674
|
+
})
|
|
675
|
+
order = statusRes.data
|
|
676
|
+
if (order?.status === 'confirmed') break
|
|
677
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
expect(order?.status).toBe('confirmed')
|
|
681
|
+
}, 30_000)
|
|
682
|
+
})
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
**Trade-offs (Docker Compose E2E):**
|
|
686
|
+
- (+) Full service isolation — no shared state with other test runs.
|
|
687
|
+
- (+) Reproducible locally — developers can run the same E2E suite on their machines.
|
|
688
|
+
- (-) Startup time. A 5-service Docker Compose stack with healthchecks takes 30-60 seconds to become ready.
|
|
689
|
+
- (-) Service build dependencies — CI must build all service images or pull from a registry.
|
|
690
|
+
- (-) Test data management is harder in a multi-service setup. Clearing state requires hitting each service's internal API or truncating databases directly.
|
|
691
|
+
|
|
692
|
+
## CI Integration Checklist for Contract Tests
|
|
693
|
+
|
|
694
|
+
A complete CI pipeline for a multi-service system runs contract tests in the correct order:
|
|
695
|
+
|
|
696
|
+
```
|
|
697
|
+
Consumer builds:
|
|
698
|
+
1. Run unit + integration tests
|
|
699
|
+
2. Run consumer contract tests → publish pact files to Pact Broker
|
|
700
|
+
|
|
701
|
+
Provider builds:
|
|
702
|
+
1. Run unit + integration tests
|
|
703
|
+
2. Fetch consumer pacts from Pact Broker
|
|
704
|
+
3. Run provider verification → publish results to Pact Broker
|
|
705
|
+
4. Run can-i-deploy check before deployment
|
|
706
|
+
|
|
707
|
+
Deployment gate:
|
|
708
|
+
5. can-i-deploy must pass for all consumers before provider deploys
|
|
709
|
+
6. can-i-deploy must pass for all providers before consumer deploys
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
**Key principle:** Never deploy a service that fails `can-i-deploy`. The Pact Broker tracks which versions of provider and consumer are compatible and will fail the check if the deployment would break a consumer.
|
|
713
|
+
|
|
714
|
+
## Common Pitfalls
|
|
715
|
+
|
|
716
|
+
**Testing the wrong level.** A team writes cross-service E2E tests for every business rule because "it's more realistic." These tests are 100x slower, flaky, and provide no more confidence than per-service integration tests for logic that lives entirely within one service. Fix: follow the test pyramid. Only cross-service E2E tests need multiple running services.
|
|
717
|
+
|
|
718
|
+
**Stale pacts.** Consumer contract files are committed to version control and drift out of sync with the actual service code. Fix: generate pacts from code, not handwritten YAML. Publish pacts to the Pact Broker on every consumer CI run. The Broker is the source of truth.
|
|
719
|
+
|
|
720
|
+
**Provider state drift.** Provider state handlers in Pact verification set up database rows that diverge from production schemas over time. Fix: use the same migration system and seed factories for provider state setup as for other integration tests. Run provider verification against a recently migrated test database.
|
|
721
|
+
|
|
722
|
+
**Fake services that lie.** An in-memory fake returns hardcoded 200s for all requests, masking integration bugs. Fix: fakes must fail on unexpected inputs (unregistered routes return 404 or 400), not silently succeed. Validate fake inputs against the same schema as the real service.
|
|
723
|
+
|
|
724
|
+
**E2E test pollution.** Tests create data but do not clean up, causing later tests to see unexpected state. Fix: each E2E test must own its test data. Use unique identifiers (timestamps, UUIDs) per test run. Provide a teardown or seed reset mechanism between test scenarios.
|
|
725
|
+
|
|
726
|
+
**Missing contract tests for async events.** Teams use Pact for HTTP APIs but leave Kafka events untested. A producer changes an event schema without updating consumers. Fix: use schema registry with compatibility enforcement for all Kafka events. Treat schema compatibility checks as contract tests for async events.
|
|
727
|
+
|
|
728
|
+
**Can-i-deploy bypassed.** A developer bypasses the can-i-deploy gate because it is slow or flapping. Fix: can-i-deploy is a hard gate. If it is slow, optimize the Pact Broker setup. If it is failing, investigate the failing contract — do not bypass.
|