Writing E2E Tests for Axum & GraphQL

Writing E2E Tests for Axum & GraphQL

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.

The Project

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:

  1. GraphQL
  2. Services (Domain Logic)
  3. 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.

The Stack

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.

Using the Cargo Workspace layout with a dedicated crate for E2E tests

Gabble project uses the Workspace Crate Layout to split logic in different crates and split respossibilities, the crates are the following:

  • Core: Business Logic (Based on Domain Driven Design fundamentals)
  • CLI: Developer Commands
  • Database: Database logic implementations
  • Server: GraphQL Server logic
  • Test: Here is where our E2E tests live!
💭

You can read more on Cargo Workspaces here !

Writing a test compliant GraphQL Server with Async-GraphQL

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:

Define your Schema instance

The same Schema used on production must be the same Schema used for testing, this is the only way to guarantee coverage in your code.

use std::sync::Arc;

use async_graphql::{EmptySubscription, Schema};

use crate::context::Context;

pub use super::modules::{MutationRoot, QueryRoot};

pub type GraphQLSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;

pub fn build_schema_with_context(context: &Arc<Context>) -> GraphQLSchema {
    Schema::build(
        QueryRoot::default(),
        MutationRoot::default(),
        EmptySubscription,
    )
    .data(Arc::clone(context))
    .finish()
}

Source Code

Note how the Context is injected to the Schema instance, let’s check on the Contex’s source code and see what it is about.

use std::sync::Arc;

use crate::config::Config;
use crate::services::{Services, SharedServices};

#[derive(Clone)]
pub struct Context {
    pub services: SharedServices,
}

pub type SharedContext = Arc<Context>;

impl Context {
    pub async fn new(config: &Config) -> Self {
        let services = Services::shared(config).await;

        Self { services }
    }

    pub async fn shared(config: &Config) -> SharedContext {
        let context = Self::new(config).await;

        Arc::new(context)
    }
}

Source Code

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.

use std::env;

pub struct Config {
    pub database_url: String,
    pub jwt_secret: String,
    pub server_port: u16,
}

impl Default for Config {
    fn default() -> Self {
        Self::new()
    }
}

impl Config {
    pub fn new() -> Self {
        let database_url = env::var("DATABASE_URL").unwrap();
        let jwt_secret = env::var("JWT_SECRET").unwrap();
        let server_port = env::var("PORT").unwrap().parse::<u16>().unwrap();

        Self {
            database_url,
            jwt_secret,
            server_port,
        }
    }
}

Source Code

With this setup in place we should be able to jump into tests implementation.

Writing the E2E Tests

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
use std::sync::Arc;

use anyhow::Result;
use pxid::Pxid;

use database::Database;
use libserver::config::Config;
use libserver::context::Context;
use libserver::graphql::schema::{build_schema_with_context, GraphQLSchema};
use libserver::services::auth::Token;

pub const TEST_ADMIN_EMAIL: &str = "[email protected]";
pub const TEST_ADMIN_PASSWORD: &str = "R00tP@ssw0rd";
pub const TEST_JWT_SECRET: &str = "test-jwt-value";

#[cfg(test)]
mod modules;

pub struct TestUtil {
    pub context: Arc<Context>,
    pub db: Database,
    pub schema: GraphQLSchema,
}

impl TestUtil {
    pub async fn new() -> 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;
    /// ```
    ///
    pub async fn new_cleared() -> Self {
        let test_util = Self::new().await.expect("Failed to create TestUtil");

        test_util.clear_db().await;

        test_util
    }

    pub async fn clear_db(&self) {
        self.db.refresh().await.expect("Failed to refresh database");
    }

    pub fn parts(&self) -> (Arc<Context>, GraphQLSchema) {
        (Arc::clone(&self.context), self.schema.clone())
    }

    pub async fn token_create(&self, uid: Pxid) -> Token {
        self.context.services.auth.sign_token(uid).unwrap()
    }
}

Source Code

Feel free to provide handy methods on this struct so you can leverage “chores” to these methods and focus on your tests.

Then let’s go and implement our tests! Create a module for one of your tests, I will use the user_register mutation from Gabble!

Bring into context the required dependencies:

use async_graphql::{Request, Variables};
use serde_json::json;

use crate::TestUtil;

Then provide a descriptive name to the test and decorate it with the test macro of your async executor of choice.

Use TestUtil::new_cleared to clean up the database:

#[tokio::test]
async fn creates_a_new_user() {
    let test_util = TestUtil::new_cleared().await;
    let (_, schema) = test_util.parts();

Then write the GraphQL mutation to test against:

    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());

Source Code

Finally execute your tests using the integrated cargo test runner via:

cargo test 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!

Conclusion

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.

Happy Hacking!

whoami

Esteban Borai

Hi there! I'm a Rust Software Engineer with 8 years of experience in Systems and Web Programming using Rust & TypeScript.

I'm passionate about Open-Source and enjoy reading books, working out & playing videogames.

I've had the opportunity to work with companies like InfinyOn, GOintegro & Teleperformance.