Skip to content

Commit

Permalink
feat: Add support for TestContainers (pgsql + redis modules)
Browse files Browse the repository at this point in the history
Add support for TestContainers to make it easy to set up independent
test environments to allow running tests in parallel without risk of
cross-contamination even if they touch the DB.

TestContainers support is behind the `test-containers` flag. When the
flag is enabled, the consumer can enable TestContainers for the DB and
Sidekiq Redis in the `AppConfig`, in which case a TestContainer
instance will be create when building the `AppContext`, and the URI in
the `AppConfig` will be updated to match that of the TestContainer
instance.

Additionally, a new method was added to `AppConfig` to allow consumers
to create it themselves. This would allow consumers more control over
the config, including manually creating a TestContainer instance and
updating the URI of a field in the `AppConfig` before loading the
application.

Finally, a new `app::init_state` method was added to allow getting
access to a real `AppState` instance in tests. This method performs most
of the setup of the app without actually running the app. This is
similar but slightly different to the existing `app::prepare` method,
and is intended to only be used in tests.

Closes #500
  • Loading branch information
spencewenski committed Nov 12, 2024
1 parent 357042a commit c6520a2
Show file tree
Hide file tree
Showing 8 changed files with 380 additions and 55 deletions.
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ jwt-openid = ["jwt"]
cli = ["dep:clap"]
otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", "dep:tracing-opentelemetry", "dep:prost"]
grpc = ["dep:tonic"]
testing = ["dep:insta", "dep:rstest"]
testing = ["dep:insta", "dep:rstest", "dep:testcontainers-modules"]
test-containers = ["testing", "dep:testcontainers-modules"]
config-yml = ["config/yaml"]

[dependencies]
Expand Down Expand Up @@ -94,6 +95,7 @@ tonic = { workspace = true, optional = true }
# Testing
insta = { workspace = true, optional = true }
rstest = { workspace = true, optional = true }
testcontainers-modules = { workspace = true, features = ["postgres", "redis"], optional = true }

# Others
anyhow = { workspace = true }
Expand Down Expand Up @@ -171,6 +173,7 @@ rusty-sidekiq = { version = "0.11.0", default-features = false }
# Testing
insta = { version = "1.39.0", features = ["toml", "filters"] }
rstest = { version = "0.23.0", default-features = false }
testcontainers-modules = { version = "0.11.3" }

# Others
# Todo: minimize tokio features included in `roadster`
Expand Down
16 changes: 14 additions & 2 deletions src/api/core/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ pub async fn health_check<S>(
state: &S,
duration: Option<Duration>,
) -> RoadsterResult<HeathCheckResponse>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
{
let context = AppContext::from_ref(state);
health_check_with_checks(context.health_checks(), duration).await
}

#[instrument(skip_all)]
pub(crate) async fn health_check_with_checks<S>(
checks: Vec<Arc<dyn HealthCheck>>,
duration: Option<Duration>,
) -> RoadsterResult<HeathCheckResponse>
where
S: Clone + Send + Sync + 'static,
AppContext: FromRef<S>,
Expand All @@ -49,10 +62,9 @@ where
} else {
info!("Running checks");
}
let context = AppContext::from_ref(state);
let timer = Instant::now();

let check_futures = context.health_checks().into_iter().map(|check| {
let check_futures = checks.into_iter().map(|check| {
Box::pin(async move {
let name = check.name();
info!(%name, "Running check");
Expand Down
105 changes: 104 additions & 1 deletion src/app/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ impl AppContext {
#[cfg_attr(test, allow(dead_code))]
pub(crate) async fn new<A, S>(
#[allow(unused_variables)] app: &A,
config: AppConfig,
#[cfg(not(feature = "test-containers"))] config: AppConfig,
#[cfg(feature = "test-containers")]
#[allow(unused_mut)]
mut config: AppConfig,
metadata: AppMetadata,
) -> RoadsterResult<Self>
where
Expand All @@ -40,6 +43,11 @@ impl AppContext {

#[cfg(not(test))]
let context = {
#[cfg(all(feature = "db-sql", feature = "test-containers"))]
let db_test_container = db_test_container(&mut config).await?;
#[cfg(all(feature = "sidekiq", feature = "test-containers"))]
let sidekiq_redis_test_container = sidekiq_redis_test_container(&mut config).await?;

#[cfg(feature = "db-sql")]
let db = sea_orm::Database::connect(app.db_connection_options(&config)?).await?;

Expand Down Expand Up @@ -89,10 +97,14 @@ impl AppContext {
health_checks: OnceLock::new(),
#[cfg(feature = "db-sql")]
db,
#[cfg(all(feature = "db-sql", feature = "test-containers"))]
db_test_container,
#[cfg(feature = "sidekiq")]
redis_enqueue,
#[cfg(feature = "sidekiq")]
redis_fetch,
#[cfg(all(feature = "sidekiq", feature = "test-containers"))]
sidekiq_redis_test_container,
#[cfg(feature = "email-smtp")]
smtp,
#[cfg(feature = "email-sendgrid")]
Expand Down Expand Up @@ -184,19 +196,110 @@ impl AppContext {
}
}

#[cfg(all(feature = "db-sql", feature = "test-containers"))]
pub async fn db_test_container(
config: &mut AppConfig,
) -> RoadsterResult<
Option<
testcontainers_modules::testcontainers::ContainerAsync<
testcontainers_modules::postgres::Postgres,
>,
>,
> {
use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::ImageExt;

let container = if let Some(test_container) = config.database.test_container.as_ref() {
let container = testcontainers_modules::postgres::Postgres::default()
.with_tag(test_container.tag.to_string())
.start()
.await
.map_err(|err| anyhow!("{err}"))?;
Some(container)
} else {
None
};

if let Some(container) = container.as_ref() {
let host_ip = container.get_host().await.map_err(|err| anyhow!("{err}"))?;

let host_port = container
.get_host_port_ipv4(5432)
.await
.map_err(|err| anyhow!("{err}"))?;

config.database.uri =
format!("postgres://postgres:postgres@{host_ip}:{host_port}/postgres").parse()?;
}
Ok(container)
}

#[cfg(all(feature = "sidekiq", feature = "test-containers"))]
pub async fn sidekiq_redis_test_container(
config: &mut AppConfig,
) -> RoadsterResult<
Option<
testcontainers_modules::testcontainers::ContainerAsync<
testcontainers_modules::redis::Redis,
>,
>,
> {
use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::ImageExt;

let container =
if let Some(test_container) = config.service.sidekiq.custom.redis.test_container.as_ref() {
let container = testcontainers_modules::redis::Redis::default()
.with_tag(test_container.tag.to_string())
.start()
.await
.map_err(|err| anyhow!("{err}"))?;
Some(container)
} else {
None
};

if let Some(container) = container.as_ref() {
let host_ip = container.get_host().await.map_err(|err| anyhow!("{err}"))?;

let host_port = container
.get_host_port_ipv4(testcontainers_modules::redis::REDIS_PORT)
.await
.map_err(|err| anyhow!("{err}"))?;

config.service.sidekiq.custom.redis.uri =
format!("redis://{host_ip}:{host_port}").parse()?;
}
Ok(container)
}

struct AppContextInner {
config: AppConfig,
metadata: AppMetadata,
health_checks: OnceLock<HealthCheckRegistry>,
#[cfg(feature = "db-sql")]
db: DatabaseConnection,
#[cfg(all(feature = "db-sql", feature = "test-containers"))]
#[allow(dead_code)]
db_test_container: Option<
testcontainers_modules::testcontainers::ContainerAsync<
testcontainers_modules::postgres::Postgres,
>,
>,
#[cfg(feature = "sidekiq")]
redis_enqueue: sidekiq::RedisPool,
/// The Redis connection pool used by [sidekiq::Processor] to fetch Sidekiq jobs from Redis.
/// May be `None` if the [fetch_pool.max_connections][crate::config::service::worker::sidekiq::ConnectionPool]
/// config is set to zero, in which case the [sidekiq::Processor] would also not be started.
#[cfg(feature = "sidekiq")]
redis_fetch: Option<sidekiq::RedisPool>,
#[cfg(all(feature = "sidekiq", feature = "test-containers"))]
#[allow(dead_code)]
sidekiq_redis_test_container: Option<
testcontainers_modules::testcontainers::ContainerAsync<
testcontainers_modules::redis::Redis,
>,
>,
#[cfg(feature = "email-smtp")]
smtp: lettre::SmtpTransport,
#[cfg(feature = "email-sendgrid")]
Expand Down
Loading

0 comments on commit c6520a2

Please sign in to comment.