Files
suaveplan/openspec/specs/testing-integration-redis/spec.md
2026-05-27 23:29:53 +01:00

7.2 KiB

testing-integration-redis

Purpose

Redis 7 ServiceFixture for integration tests. Provides env-bridge passthrough (REDIS_URL), TCP PING health checking, injectable TCP-ping function, and URL parsing for passthrough mode. Enables real Redis integration tests without mocks. Depends on @suaveplan/testing-integration and testcontainers.

Requirements

Requirement: RedisFixture implements ServiceFixture

The package SHALL export RedisFixture implementing ServiceFixture<RedisConfig> with name === "redis". RedisConfig SHALL be { url: string; host: string; port: number }. The fixture MUST default to the REDIS_IMAGE constant and expose REDIS_CONTAINER_PORT.

Scenario: name property is "redis"

  • WHEN an instance's .name is read
  • THEN the value is the literal "redis"

Verified by: src/fixture/redis-fixture.test.ts::"name property returns redis"


Requirement: Env-bridge passthrough mode

When REDIS_URL is set and non-empty, start() MUST parse it via new URL(...) and return { url, host, port } directly without starting a container. The fallback port (when the URL omits one) MUST be REDIS_CONTAINER_PORT. In passthrough mode stop() MUST be a no-op.

Scenario: parses config from REDIS_URL

  • WHEN REDIS_URL=redis://h:1234 is set
  • THEN start() returns { url, host: "h", port: 1234 } without invoking testcontainers

Scenario: uses default port when URL has no port

  • WHEN REDIS_URL=redis://h is set
  • THEN start() returns port: REDIS_CONTAINER_PORT

Scenario: stop is a no-op in passthrough mode

  • WHEN start() ran in passthrough mode and stop() is called
  • THEN no container is touched

Verified by: src/fixture/redis-fixture.test.ts::"passthrough mode: parses config from REDIS_URL" AND src/fixture/redis-fixture.test.ts::"passthrough mode: uses default port when URL has no port" AND src/fixture/redis-fixture.test.ts::"passthrough mode: stop is a no-op"


Requirement: Container mode

When no REDIS_URL is set, start() MUST start a Redis container via the injected containerFactory (or testcontainers GenericContainer when no factory is injected), expose REDIS_CONTAINER_PORT, and return { url: "redis://localhost:<mappedPort>", host: "localhost", port: <mappedPort> }. stop() MUST call container.stop() exactly once. Container image MUST be overridable via options.image.

Scenario: returns correct config from mapped port

  • WHEN start() boots a container that maps the Redis port
  • THEN result.url, result.host, and result.port reflect the mapped port

Scenario: stop calls container.stop()

  • WHEN stop() is called after start() in container mode
  • THEN the container's stop() is invoked exactly once

Scenario: image option overrides default

  • WHEN new RedisFixture({ image: "x" }) is started
  • THEN the container factory is invoked with "x"

Scenario: factory falls back to dynamic GenericContainer import

  • WHEN no containerFactory is injected
  • THEN the fixture dynamically imports testcontainers and uses GenericContainer

Verified by: src/fixture/redis-fixture.test.ts::"container mode happy path: returns correct config" AND src/fixture/redis-fixture.test.ts::"container mode: stop calls container.stop()" AND src/fixture/redis-fixture.test.ts::"image override: factory receives custom image" AND src/fixture/redis-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"


Requirement: TCP-PING health check

In container mode, healthCheck() MUST connect to localhost:<mappedPort> over TCP, send PING\r\n, and return true only when the response begins with +PONG. The TCP function MUST be injectable as options.tcpPingFn. In passthrough mode and when no container has been started, healthCheck() MUST return false.

Scenario: returns true when server replies +PONG

  • WHEN the injected ping function resolves to true
  • THEN healthCheck() resolves to true

Scenario: returns false on non-PONG / no listener

  • WHEN the injected ping function resolves to false
  • THEN healthCheck() resolves to false

Scenario: returns false when no container

  • WHEN healthCheck() is called before start() (or after passthrough mode)
  • THEN the result is false

Scenario: defaultTcpPing returns false when nothing listening

  • WHEN defaultTcpPing(<unbound port>, "localhost") is called
  • THEN it resolves to false

Scenario: defaultTcpPing returns true on +PONG response

  • WHEN the server replies +PONG\r\n
  • THEN defaultTcpPing resolves to true

Scenario: defaultTcpPing returns false on non-PONG response

  • WHEN the server replies anything other than +PONG
  • THEN defaultTcpPing resolves to false

Verified by: src/fixture/redis-fixture.test.ts::"healthCheck returns true via injected tcpPingFn" AND src/fixture/redis-fixture.test.ts::"healthCheck returns false via injected tcpPingFn" AND src/fixture/redis-fixture.test.ts::"healthCheck returns false when no container" AND src/fixture/redis-fixture.test.ts::"healthCheck defaultTcpPing: returns false when nothing listening" AND src/fixture/redis-fixture.test.ts::"healthCheck defaultTcpPing: returns true when server replies +PONG" AND src/fixture/redis-fixture.test.ts::"healthCheck defaultTcpPing: returns false when server replies non-PONG"


Requirement: stop() is idempotent

stop() MUST be safe to call when no container has been started.

Scenario: stop without start is a no-op

  • WHEN stop() is called on a freshly-constructed fixture
  • THEN no error is thrown

Verified by: src/fixture/redis-fixture.test.ts::"stop when no container is a no-op"


Requirement: passthrough healthCheck semantics — documented

In passthrough mode the fixture has no container to probe; healthCheck() returns false. Consumers using passthrough mode MUST NOT poll passthrough fixtures via HealthPoller — readiness is the operator's responsibility (the env-bridge URL is assumed to point at an already-healthy service). The TestEnvironment.setup() flow does not poll fixtures, so passthrough remains compatible with the standard lifecycle.

Scenario: passthrough mode does not call container.stop()

  • WHEN the fixture started in passthrough mode and stop() is called
  • THEN container.stop() is never invoked (there is no container)

Verified by: src/fixture/redis-fixture.test.ts::"passthrough mode: stop is a no-op"


Requirement: testing-integration is a peer dependency

The package's package.json MUST list @suaveplan/testing-integration and testcontainers in peerDependencies, not in dependencies. They MUST also appear in devDependencies so the workspace symlink resolves for the package's own tests.

Scenario: variant does not bundle the core or testcontainers

  • WHEN the published package.json is inspected
  • THEN dependencies does not contain @suaveplan/testing-integration and does not contain testcontainers

Verified by: src/fixture/redis-fixture.test.ts::"name property returns redis" (proves the variant resolves the peer at test time).