As your application grows in features you want to make sure different flows
work as expected. For isolated pieces of logic it’s very practical to use the
built-in approach for tests in cargo.
In other cases you may encounter a more complex scenario, with different layers,
from data input to database queries. You will expect an specific output given
certain input.
Consider one of my very rencent projects Gabble, a chat solution you can
host yourself.
Gabble has different layers of logic, every operation will be processed in each
layer in the following order:
GraphQL
Services (Domain Logic)
Database (PostgreSQL)
💭
Gabble stills in a very early development, but you can join us and help
building it! Visit our Discord server Whizzes,
a place to learn Rust and Svelte with English/Spanish speakers by building in
the Open Source.
Its important to make sure each layer works as expected, when changes are made
on certain layers we dont want to have regression bugs from unexpected behavior.
In order to do this we have to write tests that mimics a user from the platform.
Gabble is written in Rust, using the Axum as the HTTP Server Framework,
Async-GraphQL as the GraphQL Server library, Tokio as the Async Executor/Runtime
and SeaORM as the database ORM solution for PostgreSQL. The database runs on a
Docker container.
With Async-GraphQL you dont need to spin up the server in order to test against
it instead you could have a server decoupled version of your GraphQL schema to
run your tests against it.
If you have any logic tied to this schema like database operations, business
logic or value objects, you will encounter the GraphQL Context feature
useful to inject such logic.
Take a look to how Gabble uses the Async-GraphQL Schema struct to achieve this:
So the Context holds the Services which contain stateless logic, all the
persistance is achieved via database, to be precise, a PostgreSQL instance.
The Config determines parameters to run the application, these parameters
should not change actual behavior, but instead provide details such as
database URL, JWT signing secret, connections to use, and any other relevant
exernal resources configuration values.
Just to picture them in our your minds, let’s have a look on the Config struct.
Now that we can use our Schema with arbitrary configurations on its backend,
lets write some tests.
Checkout the src/test/src/lib.rs file, we can see a struct called
TestUtil, this struct brings methods for repetitive tasks such as:
Running database migrations
Wipping the database
Creating an authentication token
usestd::sync::Arc;useanyhow::Result;usepxid::Pxid;usedatabase::Database;uselibserver::config::Config;uselibserver::context::Context;uselibserver::graphql::schema::{build_schema_with_context,GraphQLSchema};uselibserver::services::auth::Token;pubconstTEST_ADMIN_EMAIL:&str="[email protected]";pubconstTEST_ADMIN_PASSWORD:&str="R00tP@ssw0rd";pubconstTEST_JWT_SECRET:&str="test-jwt-value";#[cfg(test)]modmodules;pubstructTestUtil{pub context:Arc<Context>,pub db:Database,pub schema:GraphQLSchema,}implTestUtil{pubasyncfnnew()->Result<Self>{dotenv::dotenv().ok();let config =Config::new();let context =Context::new(&config).await;let context =Arc::new(context);let schema =build_schema_with_context(&context);let db =Database::new(config.database_url.as_str()).await.expect("Failed to connect to DB");Ok(Self{
context,
db,
schema,})}/// Creates a new instance of [`TestUtil`] and clears the database./// This is equivalent to calling////// ```ignore/// use crate::TestUtil;////// let test_util = TestUtil::new().await;////// test_util.clear_db().await;/// ```///pubasyncfnnew_cleared()->Self{let test_util =Self::new().await.expect("Failed to create TestUtil");
test_util.clear_db().await;
test_util
}pubasyncfnclear_db(&self){self.db.refresh().await.expect("Failed to refresh database");}pubfnparts(&self)->(Arc<Context>,GraphQLSchema){(Arc::clone(&self.context),self.schema.clone())}pubasyncfntoken_create(&self, uid:Pxid)->Token{self.context.services.auth.sign_token(uid).unwrap()}}
let mutation:&str="
mutation RegisterUser($payload: UserRegisterInput!) {
userRegister(input: $payload) {
user {
id
name
surname
username
email
createdAt
updatedAt
}
error {
code
message
}
}
}
";
Now let’s execute the request! To do this we build a Request instance
providing the mutation and provide any desired variables.
let result = schema
.execute(Request::new(mutation).variables(Variables::from_json(json!({"payload":{"name":"John","surname":"Appleseed","username":"john_appleseed","password":"Root$1234","email":"[email protected]",}}))),).await;
Finally test your assertions on the response, keep in mind that the returned
value is actually a JSON instance so you must access its keys using the
Index operator and &'static str instances!
let data = result.data.into_json().unwrap();let user_register_user =&data["userRegister"]["user"];assert_eq!(user_register_user["name"],"John");assert_eq!(user_register_user["surname"],"Appleseed");assert_eq!(user_register_user["email"],"[email protected]");assert_eq!(user_register_user["username"],"john_appleseed");assert!(user_register_user["createdAt"].is_string());assert!(user_register_user["updatedAt"].is_string());
Finally execute your tests using the integrated cargo test runner via:
cargotest creates_a_new_user
Don’t forget to have your database running in the specified URL and any other
required resources in your project are running and configured as expected!
In this note we can find insights and ideas on how we can setup our tests to
run against the actual environment! Make sure you test your GraphQL operations
and keep tuned for more notes.