Testing
Every test with infrastructure of its own
Encore.rs tests are ordinary Rust tests: #[test], #[tokio::test], or #[encore::test]. Run them with encore test, and each test gets infrastructure of its own: a migrated database no other test writes to, an empty bucket, an empty Redis database, and topics nobody else publishes to. Tests can run in parallel without seeing each other's data, and need no cleanup.
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn adds_a_user() -> Result<(), encore::Error> {
add_user("ada").await?;
let row = DB.query_one("SELECT count(*) FROM users", &[]).await?;
assert_eq!(row.get::<_, i64>(0), 1);
Ok(())
}
}
Running tests
encore test runs every service's tests, several services at a time. For a Rust service it runs cargo test in the service's directory, with the SDK's local feature:
encore test # every serviceencore test --services users # some services: an expression like `users | billing`encore test --concurrency 2 # at most two services at a time
Pass a command to run it instead of the services' own. Without --services it runs once, in the current directory; with --services it runs in each selected service's directory:
encore test cargo test --features encore-dev/local adds_a_userencore test --services users cargo test --features encore-dev/local -- --nocapture
A command you pass gets no features from Encore, so name encore-dev/local yourself, or buckets and Pub/Sub won't work.
Run tests with encore test, not plain cargo test: the SDK needs the infrastructure encore test provides, and a test binary started without it can't initialize the SDK and exits.
What each test gets
Encore provisions a test's resources the first time the test uses them, and releases them when the test ends.
connection_string() on a database returns the connection string of the test's own database, so a client such as sqlx sees the same data the test does.
Under encore test no other service runs, so encore::services::base_url panics. Test a service's HTTP handlers in-process, by calling them or by sending requests to your router directly.
Which test a call belongs to
Encore works out which test a call belongs to from the thread it is made on:
- The test's own thread, which the test harness names after the test.
- A thread of the Tokio runtime the test runs in, so
tokio::spawnandspawn_blockingfrom a#[tokio::test]are part of the test.
A thread the test starts itself is neither, and using a resource there is an error. Make the thread part of the test by entering the test's scope on it:
#[tokio::test]
async fn counts_on_another_thread() -> Result<(), encore::Error> {
let scope = encore::testing::current()?;
let thread = std::thread::spawn(move || {
let _guard = scope.enter();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(DB.query_one("SELECT count(*) FROM users", &[]))
.map(|row| row.get::<_, i64>(0))
});
// Wait for it without blocking the test's own runtime.
let count = tokio::task::spawn_blocking(move || thread.join().unwrap())
.await
.unwrap()?;
assert_eq!(count, 0);
Ok(())
}
encore::testing::current()returns aTestScopefor the current test. It isCloneandSend, and fails outsideencore test.TestScope::enter()makes the calling thread part of the test until the returned guard is dropped.TestScope::scope(future)runs a future as part of the test on whatever thread polls it. Use it for work spawned onto a runtime that several tests share.TestScope::name()returns the test's name as the harness reports it, such astests::adds_a_user.
A test's infrastructure lives as long as the test, or a TestScope for it, does.
#[encore::test]
#[encore::test] is #[tokio::test] that tells Encore about the test before it runs, instead of leaving Encore to work it out at the first call. Its runtime's threads are part of the test from the start, its infrastructure is released as soon as it returns, and it loads its module's fixture.
#[encore::test]
async fn adds_a_user() -> Result<(), encore::Error> {
// …
Ok(())
}
#[encore::test(flavor = "multi_thread", worker_threads = 2)]
async fn handles_concurrent_writes() {
// …
}
It takes the same flavor ("current_thread", the default, or "multi_thread") and worker_threads as #[tokio::test], and a fixture argument. It also works on a test that isn't async, and keeps the test's other attributes, such as #[should_panic].
Testing Pub/Sub
Under encore test nothing handles what a test publishes. Read back what the test, and the code it called, published to a topic with encore::testing::published, and call a subscription's handler yourself when you want it to run:
static USER_ADDED: Topic<UserAdded> = Topic::new("user-added");
#[encore::pubsub::register]
static GREET: Subscription<UserAdded> = Subscription::new(&USER_ADDED, "greet", greet);
async fn greet(msg: Message<UserAdded>) -> Result<(), encore::Error> {
// …
Ok(())
}
#[tokio::test]
async fn greets_new_users() -> Result<(), encore::Error> {
add_user("ada").await?;
let published = encore::testing::published(&USER_ADDED)?;
assert_eq!(published.len(), 1);
assert_eq!(published[0].data.name, "ada");
greet(published[0].clone()).await?;
Ok(())
}
published returns the messages in the order they were published, each a Message with its data, raw_data, id, attributes and, on an ordered topic, ordering_key.
Secrets and config
A test can give a secret or a config a value of its own. No other test sees it, and it is gone when the test ends:
#[tokio::test]
async fn uses_the_test_key() -> Result<(), encore::Error> {
encore::testing::set_secret(&API_KEY, "test-key")?;
assert_eq!(*API_KEY.value()?, "test-key");
encore::testing::set_config(&CHECKOUT, serde_json::json!({ "max_items": 2 }))?;
assert_eq!(CHECKOUT.get().max_items, 2);
Ok(())
}
set_secret takes the value as bytes or a string. For a Secret<Json<T>>, pass the JSON. set_config checks the value against the config's schema and reads it as the config's type, and returns an error if it doesn't fit.
Fixtures
A test starts from freshly provisioned resources: migrated but otherwise empty databases, empty buckets and empty Redis databases. A fixture is a state to start from instead. Its setup is ordinary app code that runs once per test run, and each test that loads the fixture gets its own copy of what the setup left, to change as it likes.
Declare fixtures with encore::fixture!, and load one at the start of a test:
#[cfg(test)]
mod tests {
use super::*;
encore::fixture! {
/// Two users and their roster.
static TEAM = async {
add_user("ada").await?;
add_user("grace").await?;
UPLOADS.upload("roster.txt", b"ada,grace".to_vec()).await?;
Ok(())
};
}
#[tokio::test]
async fn lists_the_team() -> Result<(), encore::Error> {
TEAM.load().await?;
assert_eq!(list_users().await?.len(), 2);
Ok(())
}
}
The setup is an expression producing a future, such as an async block or a call to an async fn, that returns Result<(), encore::Error>.
Rules to know:
- Load first. Call
load()before the test first uses a resource. Once a test has a database, bucket or Redis database, what it started from can't change, andload()returns an error. - What is saved. Databases, buckets and Redis databases are saved. A resource the setup didn't touch starts as it would without the fixture. Topics, secrets and config start each test as usual.
- When the setup runs. The first test of a run to load a fixture builds it, and the others wait for it, across threads and test binaries. Encore keeps a fixture between runs while the app's source is unchanged, unless its setup touched a Redis database: such a fixture is built again each run.
- Names.
fixture!names each fixture after its module and itself. Two fixtures of the same name are one fixture.
Extending a fixture
A fixture declared static NAME: PARENT starts its setup from what PARENT saved:
encore::fixture! {
static TEAM = async {
add_user("ada").await?;
add_user("grace").await?;
Ok(())
};
/// `TEAM`, and one more.
static BIGGER_TEAM: TEAM = async {
add_user("linus").await?;
Ok(())
};
}
A module's fixture
for mod makes a fixture the one every #[encore::test] in its module, and in the modules inside it, loads before it runs. The innermost module's fixture wins, and a test can choose another, or none:
mod with_team {
use super::*;
encore::fixture! {
static SEEDED for mod: TEAM = async {
add_user("barbara").await?;
Ok(())
};
}
#[encore::test]
async fn starts_from_seeded() {
// Three users from TEAM and SEEDED.
}
#[encore::test(fixture = TEAM)]
async fn starts_from_team() {
// TEAM's two users.
}
#[encore::test(fixture = none)]
async fn starts_empty() {
// No fixture.
}
}
A plain #[tokio::test] in such a module loads nothing by itself. Rather than run it on the wrong data, Encore returns an error at its first use of a resource until it calls SEEDED.load().await? or encore::testing::without_fixture()?.
Declaring a fixture by hand
fixture! declares a Fixture. You can declare one directly, which suits a fixture shared between modules:
use encore::testing::Fixture;
pub static SEEDED: Fixture = Fixture::new("seeded", || Box::pin(seed()));
pub static INVOICED: Fixture = Fixture::extending(&SEEDED, "invoiced", || Box::pin(invoice()));
async fn seed() -> Result<(), encore::Error> {
DB.execute("INSERT INTO users (name) VALUES ('ada'), ('grace')", &[]).await?;
Ok(())
}
async fn invoice() -> Result<(), encore::Error> {
// …
Ok(())
}
The name identifies the fixture across the app's test binaries.