Introduction
We have come thus far. From a skeletal project to a feature-complete authentication system. Boldly, we can pat ourselves on the back for a job well done. But wait, we haven't completed the development phase of Software Engineering. Though we have manually tested our backend service and frontend application, automated tests are still missing. Not everyone has the luxury to manually browse through your application to test whether or not something is broken. We can assist those persons by providing some tests which can easily be run to confirm the correctness of our development efforts. This article will try to lay some foundations for testing our current system. You are at liberty to include stuff such as mocking of function calls and the rest. The project's repository awaits your PRs!
Source code
The source code for this series is hosted on GitHub via:
rust-auth
A full-stack secure and performant authentication system using rust's Actix web and JavaScript's SvelteKit.
This application resulted from this series of articles and it's currently live here(I have disabled the backend from running live).
Run locally
You can run the application locally by first cloning it:
~/$ git clone https://github.com/Sirneij/rust-auth.git
After that, change directory into each subdirectory: backend
and frontend
in different terminals. Then following the instructions in each subdirectory to run them.
Implementation
Step 1: Automated backend testing
You can get the overview of the code for this section on github.
When starting, we made some design decisions at the backend. The decision will allow us to independently test the service without interfering with the real application using a term called integration testing. We'll utilize two "dev" packages: reqwest and fake. Dev dependencies only get introduced into your application in development or during testing. In production, they are not included:
~/rust-auth/backend$ cargo add --dev reqwest --features json,cookies,rustls-tls
~/rust-auth/backend$ cargo add --dev fake
For reqwest, we activated json
, cookies
, and rustls-tls
in addition to its default features. json
provides serialization and deserialization for JSON bodies; cookies
allows cookie session support; and rustls-tls
enables TLS functionality provided by rustls
. Now to the test proper.
Create an api
subfolder in the tests
folder. Then create a helper.rs
file which has the following content:
// backend/tests/api/helpers.rs
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use once_cell::sync::Lazy;
use sqlx::Row;
static TRACING: Lazy<()> = Lazy::new(|| {
let subscriber = backend::telemetry::get_subscriber(false);
backend::telemetry::init_subscriber(subscriber);
});
pub struct TestApp {
pub address: String,
pub test_user: TestUser,
pub api_client: reqwest::Client,
}
impl TestApp {
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(&format!("{}/users/login/", &self.address))
.json(body)
.send()
.await
.expect("Failed to execute request.")
}
}
pub async fn spawn_app(pool: sqlx::postgres::PgPool) -> TestApp {
dotenv::from_filename(".env.test").ok();
Lazy::force(&TRACING);
let settings = {
let mut s = backend::settings::get_settings().expect("Failed to read settings.");
// Use a random OS port
s.application.port = 0;
s
};
let application = backend::startup::Application::build(settings.clone(), Some(pool.clone()))
.await
.expect("Failed to build application.");
let address = format!("http://127.0.0.1:{}", application.port());
let _ = tokio::spawn(application.run_until_stopped());
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.cookie_store(true)
.build()
.unwrap();
let test_app = TestApp {
address,
test_user: TestUser::generate().await,
api_client: client,
};
test_app.test_user.store(&pool).await;
test_app
}
pub struct TestUser {
pub email: String,
pub password: String,
first_name: String,
last_name: String,
}
impl TestUser {
pub async fn generate() -> Self {
Self {
email: uuid::Uuid::new_v4().to_string(),
password: uuid::Uuid::new_v4().to_string(),
first_name: uuid::Uuid::new_v4().to_string(),
last_name: uuid::Uuid::new_v4().to_string(),
}
}
async fn store(&self, pool: &sqlx::postgres::PgPool) {
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default()
.hash_password(self.password.as_bytes(), &salt)
.expect("Unable to hash password.")
.to_string();
let user_id = sqlx::query(
"INSERT INTO users (email, password, first_name, last_name, is_active, is_staff, is_superuser)
VALUES ($1, $2, $3, $4, true, true, true) RETURNING id"
)
.bind(&self.email)
.bind(password_hash)
.bind(&self.first_name)
.bind(&self.last_name)
.map(|row: sqlx::postgres::PgRow| -> uuid::Uuid{
row.get("id")
})
.fetch_one(pool)
.await
.expect("Failed to store test user.");
sqlx::query(
"INSERT INTO user_profile (user_id)
VALUES ($1)
ON CONFLICT (user_id)
DO NOTHING",
)
.bind(user_id)
.execute(pool)
.await
.expect("Cannot store user_profile to the DB");
}
}
By now, long snippets shouldn't faze you again. What is needed is some skimming and you will get an idea of what it does. As for this snippet, we first imported a couple of things. Then we lazily initiated our telemetry
module which helps provide request/response tracing functionality. Next, a TestApp
struct that holds some important defaults of our test application was defined. A post_login
method was then defined for this struct so that with the app's instance in scope, we can easily make a login request. spawn_app
was where we built our application; and fed it with a database pool — will be supplied by SQLx — started the app with any available port; initialized our reqwest client with some defaults such as enabling cookies for all requests; gave TestApp
fields the values we wanted; created a default application test user and then returned the TestApp
. TestUser
is the struct that houses our test user and it has some pretty basic methods to help perform its job. All these are done to ease our testing experience.
In backend/tests/api/main.rs
, we made our test module:
// backend/tests/api/main.rs
mod helpers;
mod users;
This module expects that a backend/tests/api/users/mod.rs
file has been created:
// backend/tests/api/users/mod.rs
mod current_user;
mod login;
mod logout;
mod regenerate_token;
mod register;
mod update_users;
The repo currently has those tests. I want collaborators to come up with a more encompassing test suite. For this article, we will only discuss the login
, register
and update_users
tests. Other ones are very identical.
To the register
test suite:
// backend/tests/api/users/register.rs
use crate::helpers::spawn_app;
use fake::faker::{
internet::en::SafeEmail,
name::en::{FirstName, LastName, NameWithTitle},
};
use fake::Fake;
use sqlx::Row;
#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct NewUser<'a> {
email: &'a str,
password: String,
first_name: String,
last_name: String,
}
#[sqlx::test]
async fn test_register_user_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;
// Request data
let email: String = SafeEmail().fake();
let first_name: String = FirstName().fake();
let last_name: String = LastName().fake();
let password = NameWithTitle().fake();
let new_user = NewUser {
email: &email,
password,
first_name,
last_name,
};
let response = app
.api_client
.post(&format!("{}/users/register/", &app.address))
.json(&new_user)
.header("Content-Type", "application/json")
.send()
.await
.expect("Failed to execute request.");
assert!(response.status().is_success());
let saved_user = sqlx::query(
"SELECT
u.id AS u_id,
u.email AS u_email,
u.password AS u_password,
u.first_name AS u_first_name,
u.last_name AS u_last_name,
u.is_active AS u_is_active,
u.is_staff AS u_is_staff,
u.is_superuser AS u_is_superuser,
u.thumbnail AS u_thumbnail,
u.date_joined AS u_date_joined,
p.id AS p_id,
p.user_id AS p_user_id,
p.phone_number AS p_phone_number,
p.birth_date AS p_birth_date,
p.github_link AS p_github_link
FROM
users u
LEFT JOIN user_profile p ON p.user_id = u.id
WHERE
u.is_active=false AND u.email=$1
",
)
.bind(&email)
.map(|row: sqlx::postgres::PgRow| backend::types::User {
id: row.get("u_id"),
email: row.get("u_email"),
first_name: row.get("u_first_name"),
password: row.get("u_password"),
last_name: row.get("u_last_name"),
is_active: row.get("u_is_active"),
is_staff: row.get("u_is_staff"),
is_superuser: row.get("u_is_superuser"),
thumbnail: row.get("u_thumbnail"),
date_joined: row.get("u_date_joined"),
profile: backend::types::UserProfile {
id: row.get("p_id"),
user_id: row.get("p_user_id"),
phone_number: row.get("p_phone_number"),
birth_date: row.get("p_birth_date"),
github_link: row.get("p_github_link"),
},
})
.fetch_one(&pool)
.await
.expect("msg");
assert_eq!(saved_user.is_active, false);
assert_eq!(saved_user.email, email);
assert_eq!(saved_user.thumbnail, None);
assert_eq!(saved_user.profile.user_id, saved_user.id);
assert_eq!(saved_user.profile.phone_number, None)
}
#[sqlx::test]
async fn test_register_user_failure_email(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;
// First request data
let email = "backend@api.com".to_string();
let first_name: String = FirstName().fake();
let last_name: String = LastName().fake();
let password = NameWithTitle().fake();
let new_user_one = NewUser {
email: &email,
password,
first_name,
last_name,
};
let response_one = app
.api_client
.post(&format!("{}/users/register/", &app.address))
.json(&new_user_one)
.header("Content-Type", "application/json")
.send()
.await
.expect("Failed to execute request.");
assert!(response_one.status().is_success());
// First request data
let email = "backend@api.com".to_string();
let first_name: String = FirstName().fake();
let last_name: String = LastName().fake();
let password = NameWithTitle().fake();
let new_user_two = NewUser {
email: &email,
password,
first_name,
last_name,
};
let response_two = app
.api_client
.post(&format!("{}/users/register/", &app.address))
.json(&new_user_two)
.header("Content-Type", "application/json")
.send()
.await
.expect("Failed to execute request.");
assert!(response_two.status().is_client_error());
let error_response = response_two
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");
assert_eq!(
error_response.error,
"A user with that email address already exists"
);
}
We used fake to generate names, emails and passwords. Notice the #[sqlx::test]
macro. That's an incredibly nifty macro that automatically creates test databases, connects to them, applies migrations, and deletes databases. An automatic test database management suit. Under the hood, it uses either #[tokio::test]
or #[async_std::test]
depending on your choice of async runtime. To successfully use it, you must activate SQLx migrate
feature and all test functions that need to use the pool it creates need to pass the pool: sqlx::postgres::PgPool
parameter. In test_register_user_success
, we tested the success path of our register
route. All its side effects were asserted. We also test its unhappy paths. A major drawback of these tests is the absence of mocking. You are very welcome to send a PR.
Next is backend/tests/api/users/login.rs
:
// backend/tests/api/users/login.rs
use crate::helpers::spawn_app;
use fake::Fake;
#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}
#[sqlx::test]
async fn test_login_user_failure_bad_request(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;
// Act - Part 1 - Login
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: fake::faker::name::en::NameWithTitle().fake(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_client_error());
let error_response = login_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");
assert_eq!(error_response.error, "Email and password do not match");
}
#[sqlx::test]
async fn test_login_user_failure_notfound(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;
// Act - Part 1 - Login
let login_body = LoginUser {
email: fake::faker::internet::en::SafeEmail().fake(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_client_error());
let error_response = login_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");
assert_eq!(error_response.error, "A user with these details does not exist. If you registered with these details, ensure you activate your account by clicking on the link sent to your e-mail address");
}
#[sqlx::test]
async fn test_login_user_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_success());
// Check that there is cookie present
let headers = login_response.headers();
assert!(headers.get("set-cookie").is_some());
let cookie_str = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie_str.contains("sessionid="));
// Check response
let response = login_response
.json::<backend::types::UserVisible>()
.await
.expect("Cannot get user response");
assert_eq!(response.email, app.test_user.email);
assert!(response.is_active);
assert_eq!(response.id, response.profile.user_id);
}
We first ensured that its unhappy paths were properly tested before proceeding to its happy path where we ensured that the cookie is present in the response header using the:
let headers = login_response.headers();
assert!(headers.get("set-cookie").is_some());
let cookie_str = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie_str.contains("sessionid="));
Thanks to reqwest's cookie feature.
Last is testing the API endpoint that expects a multipart form:
// backend/tests/api/users/update_users.rs
use crate::helpers::spawn_app;
#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}
#[sqlx::test]
async fn test_update_user_failure_not_logged_in(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;
// multipart form
let form = reqwest::multipart::Form::new()
.text("github_link", "https://github.com/Sirneij")
.text("phone_number", "+2348135459073");
let update_user_response = app
.api_client
.patch(&format!("{}/users/update-user/", &app.address))
.multipart(form)
.send()
.await
.expect("Failed to execute request.");
// Check response
let response = update_user_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");
assert_eq!(
response.error,
"You are not logged in. Kindly ensure you are logged in and try again"
);
}
#[sqlx::test]
async fn test_update_user_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;
// First login
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_success());
// Check that there is cookie present
let headers = login_response.headers();
assert!(headers.get("set-cookie").is_some());
let cookie_str = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie_str.contains("sessionid="));
// multipart form
let form = reqwest::multipart::Form::new()
.text("github_link", "https://github.com/Sirneij")
.text("phone_number", "+2348135459073");
let update_user_response = app
.api_client
.patch(&format!("{}/users/update-user/", &app.address))
.multipart(form)
.send()
.await
.expect("Failed to execute request.");
// Check response
let response = update_user_response
.json::<backend::types::UserVisible>()
.await
.expect("Cannot get user response");
assert_eq!(response.email, app.test_user.email);
assert!(response.is_active);
assert_eq!(response.id, response.profile.user_id);
assert_eq!(
response.profile.github_link,
Some("https://github.com/Sirneij".to_string())
);
assert_eq!(
response.profile.phone_number,
Some("+2348135459073".to_string())
);
}
With reqwest, sending multipart forms is a breeze. You must activate the multipart
feature though. In these tests, we didn't upload images because I hadn't found a good way to mock AWS's s3 functionalities yet. If you do, kindly send a PR.
That's the bit about the tests. The full tests included can be found in the repo.
Step 2: Automated frontend testing
You can get the overview of the code for this section on github.
The front-end already helped us include some awesome testing libraries: vitest for unit testing and playwright for end-to-end testing. When we started our app, we already chose to use these libraries and a tests
folder was created for playwright. vitest uses any file found somewhere else that has *.test.js|ts
as part of their filenames. For this article, we will only test the home page (/
route) and login page (/auth/login
route). PRs are welcome for other routes. I also included unit tests for some helper functions.
Let's start with the home page (/
route):
// frontend/tests/index.test.ts
import { expect, test } from '@playwright/test';
test('index page has title', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle('Written articles | Actix Web & SvelteKit');
});
test('index page has h1 content', async ({ page }) => {
await page.goto('/');
expect(await page.textContent('h1')).toContain(
'Authentication system using Actix Web and Sveltekit'
);
});
test('index page has img alt content', async ({ page }) => {
await page.goto('/');
const isVisible = await page.getByAltText('Rust (actix-web) and Sveltekit').isVisible();
expect(isVisible).toBe(true);
});
test('test some elements', async ({ page }) => {
await page.goto('/');
await page
.getByRole('heading', { name: 'Authentication system using Actix Web and Sveltekit' })
.click();
await page.getByRole('link', { name: 'Login' }).click();
await page.getByRole('button', { name: '+' }).click();
});
It is pretty simple to read through. Being a first-timer with JavaScript automated testing, I fell in love with it instantly. In the tests, we're trying to be sure that those strings are indeed on the page. Next is frontend/tests/login.test.ts
:
// frontend/tests/login.test.ts
import { expect, test } from '@playwright/test';
test('login page has title, h1 and url', async ({ page }) => {
await page.goto('/auth/login');
await expect(page).toHaveTitle('Auth - Login | Actix Web & SvelteKit');
await expect(page).toHaveURL('/auth/login');
expect(await page.textContent('h1')).toBe('Login');
});
test('login page form element', async ({ page }) => {
await page.goto('/auth/login');
// Form element
const formElement = page.locator('form');
const formAction = await formElement.getAttribute('action');
expect(formAction).toBe('?/login');
// Email input
const inputField = page.getByRole('textbox', { name: 'Email address' });
await inputField.click();
await inputField.type('jane.doe@example.com');
await expect(inputField).toHaveValue('jane.doe@example.com');
// Password input
const passwordInput = page.getByRole('textbox', { name: 'Password' });
await passwordInput.click();
await passwordInput.type('mypassword');
expect(await page.inputValue("input[type='password']")).toBe('mypassword');
});
test('login page has some links', async ({ page }) => {
await page.goto('/auth/login');
await page.getByRole('link', { name: 'Create an account.' }).click();
await page.getByRole('link', { name: 'Forgot password?' }).click();
});
We took a step further to interact with the form element and its input elements. How cool is that?!
One of the unit tests included is:
// frontend/src/lib/utils/helpers/input.validation.test.ts
import { test, expect } from 'vitest';
import { isValidEmail, isValidPasswordMedium, isValidPasswordStrong } from './input.validation';
test('test isValidEmail', () => {
let email = 'good@email.com';
expect(isValidEmail(email)).toEqual(true);
email = 'bad@email';
expect(isValidEmail(email)).toEqual(false);
});
test('test isValidPasswordStrong', () => {
let password = '123456Data@gmail.Com';
expect(isValidPasswordStrong(password)).toEqual(true);
password = 'badpassword';
expect(isValidEmail(password)).toEqual(false);
});
test('test isValidPasswordMedium', () => {
let password = '123456Data';
expect(isValidPasswordMedium(password)).toEqual(true);
password = 'badpassword';
expect(isValidEmail(password)).toEqual(false);
});
These tests use the power of vitest to test the helper functions included.
That's it!!! It's finally time to draw the curtains after this long series. As stated, I welcome gigs, comments, criticisms (constructive), collaborations and all other nifty stuff... Bye for now.
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (6)
Hi John,
Your code can't be compiled. Could you inform me how to run it?
The packages seem to be outdated.
Looking forward to your reply.
Thanks
Hi Jaer,
Can you explain more on this? I am very sure that some of the packages have latest revisions but I haven't had time to update them yet.
Hi John,
Thank you for replying.
Below is the snippet of the error output.
Appreciate if you could give me some hints on how to solve this issue. Thanks
error[E0433]: failed to resolve: unresolved import
sqlx::types`--> src/routes/users/register.rs:64:24
|
64 | crate::types::ErrorResponse {
| ^^^^^
| |
| unresolved import
| help: a similar path exists:
error[E0433]: failed to resolve: unresolved import
--> src/routes/users/register.rs:68:24
|
68 | crate::types::ErrorResponse {
| ^^^^^
| |
| unresolved import
| help: a similar path exists:
sqlx::types
error[E0433]: failed to resolve: unresolved import
--> src/routes/users/register.rs:81:72
|
81 | actix_web::HttpResponse::InternalServerError().json(crate::types::ErrorResponse {
| ^^^^^
| |
| unresolved import
| help: a similar path exists:
sqlx::types
error[E0433]: failed to resolve: could not find
utils
in the crate root--> src/routes/users/register.rs:87:12
|
87 | crate::utils::send_multipart_email(
| ^^^^^ could not find
utils
in the crate rooterror[E0433]: failed to resolve: unresolved import
--> src/routes/users/register.rs:104:47
|
104 | actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse {
| ^^^^^
| |
| unresolved import`
I just cloned the repo. Updated the packages using
cargo update
and my rust version usingrust update
. Everything compiles without any error. I have even pushed a small update to github.Hi John,
Thanks for the highlight. But the issues during the compilation like this:
$ cargo run
target/debug/backendFinished dev [unoptimized + debuginfo] target(s) in 0.75s
Running
RUST_BACKTRACE=1thread 'main' panicked at /Users/eric/workspace/Rust/github/rust-auth/backend/src/startup.rs:111:54:
Failed to get AWS key.: NotPresent
note: run with
environment variable to display a backtrace
Could you show me how to solve this issue?
Thanks
Kindly read what the error says. You need to provide your AWS S3 credentials. I included
.env.test
. It contains the required data. To run the app, create a.env.development
file and put in the following data:Modify them accordingly.