Unit tests in Rust

How to Write Unit Tests in Rust for Tauri?

Tauri is a desktop apps framework built with Rust for its backend and JavaScript for its front end. The first thing I like to do in a project is set up a unit tests infrastructure. I found it surprisingly easy to do it in Rust.

It has become a summer tradition with my son: we start developing a game together. This time, we thought of creating a desktop game.

After a long time on my learning list, I finally had an excuse to use Tauri. Building the app went pretty smoothly (more on that in upcoming posts), but I was missing how to unit test the Rust code.

The Bare Tauri Project

A Tauri project is composed of two main parts: Rust and JavaScript. Creating it is easy with a simple npm command: npm create tauri-app (you can replace npm with your favorite JS package manager). I’ve used it to scaffold a basic Tauri boilerplate:

Folder Structure of a Tauri Project

The src folder holds the JavaScript code. We’ll get back to it in later posts when I’ll tell you about my amazing Desktop app.

The src-tauri folder holds the Rust code. Let’s get inside.

Inside the tauri-src folder

Inside we can see the icons folder, which holds icons for our desktop app and the src which holds our app. Our app consists of only one file – main.rs. Makes sense, as this the beginning of our project.

main.rs is pretty simple:

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
view raw main.rs hosted with ❤ by GitHub

The first line is a convention meant to help us build for windows.

The #[tauri::command] allows us to build API commands for Tauri. It acts as a decorator to add more characteristics to the following code. We’ll get back to it in later posts.

Then I have the greet function, which is the simple function we’re going to see a lot in this article.

fn main starts the Rust app. It starts the Tauri builder and does some stuff. The important thing here is the invoke_handler part. You see it adds a handler with greet? This sets up the API between the client and the Rust server (remember that tauri::command decorator?).

I know you want to get to business. Don’t worry! That’s the last time Tauri gets in the way of our story 😉

Now, we came here to test greet and make sure it does what we want, right?

How to Write and Run Unit Tests in a Rust Project

There are two places where you can add tests in Rust. The module file itself or a test folder inside the module. We’ll talk about this later.

Our test code is going to test that when we call greet with a name, it returns the expected string with the name. We will write two tests. One that passes and one that fails. Here’s how it looks like. Don’t worry, we’ll dive into the code in a bit 🙂

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_greet() {
assert_eq!(greet("Johnny"), "Hello, Johnny! You've been greeted from Rust!");
}
#[test]
fn test_greet_badly() {
assert_eq!(greet("Johnny"), "Hello, Bonny! You've been greeted from Rust!");
}
}
view raw main_test.rs hosted with ❤ by GitHub

The code above is how you write unit tests in Rust.

[cfg(test)] is actually “configuration(test)” and it tells the compiler to ignore this on build and run it on test.

The next lines define a module (mod tests). When you use super::* that means you “import” all the properties and methods of the current module. This is why I’m able to use greet in my test functions. Which, at long last, brings us to our test functions.

fn test_greet and fn test_greet_badly are simple test functions that assert greet‘s output.

assert_eq!(greet("Johnny"), "Hello, Johnny! You've been greeted from Rust!"); does exactly what you’d expect: asserts that the output of greet("Johnny") equals to the string.

Let’s keep it simple and add this whole code to the main.rs file, just below the main function.

In order to run tests in Rust, we use the command cargo test. Here are the results:

The results of the tests run. As expected, it passed for test_greet but failed for test_greet_badly

Assuming you are not overwhelmed by the usage of cargo so far, let’s continue to the next step: modularizing our code.

How to Modularize Rust Code

Right now, we have our test inside our module. We actually have our greet function inside the main.rs file. It looks like this:

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_greet() {
assert_eq!(greet("Johnny"), "Hello, Johnny! You've been greeted from Rust!");
}
#[test]
fn test_greet_badly() {
assert_eq!(greet("Johnny"), "Hello, Bonny! You've been greeted from Rust!");
}
}
view raw main.rs hosted with ❤ by GitHub

That’s fine now, but this will get more complicated as the project grows. Let’s break down the Rust code into modules.

In Rust, modules are either files or folders with a mod.rs file in them.

So we could extract greet to a greet.rs file and then import it in our main file like this:

#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_greet() {
assert_eq!(greet("Johnny"), "Hello, Johnny! You've been greeted from Rust!");
}
#[test]
fn test_greet_badly() {
assert_eq!(greet("Johnny"), "Hello, Bonny! You've been greeted from Rust!");
}
}
view raw greet.rs hosted with ❤ by GitHub
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod greet;
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![greet::greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
view raw main.rs hosted with ❤ by GitHub

main.rs is now much smaller. All of our logic resides in the greet.rs file. Notice we made the function public (the pub preceding the function). We import the module via the mod greet line. Rust expects the file greet.rs to reside in the same folder as main.rs.

The line that changed is our invoke handler:

.invoke_handler(tauri::generate_handler![greet::greet])

Notice we are using greet:greet. Because I’m used to JavaScript, I look at it like object notation. Instead of greet.greet, we now have greet::greet. Not a big deal 🙂

In JavaScript we also have named imports. This can be done using the use command like this:

mod greet;
use crate::greet::greet;

Then in our code, we can use greet without mentioning its module:

.invoke_handler(tauri::generate_handler![greet])

Now we have our main file and a way to modularize our code. Our tests are still in the implementation file, though. Let’s fix that.

How to Separate Tests from Implementation in Rust

I mentioned earlier that a module can also be a folder with a mod.rs file. That means, we can create a greet folder with mod.rs file and it will work the same:

Module as a folder

Nothing changed except greet.rs turned into greet/mod.rs.

We can use that in order to extract the tests if we create a module test inside greet:

Extracting the greet tests into a test module

Our files are going to look like this:

mod test;
#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
view raw greet.mod.rs hosted with ❤ by GitHub
use crate::greet;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_greet() {
assert_eq!(greet("Johnny"), "Hello, Johnny! You've been greeted from Rust!");
}
#[test]
fn test_greet_badly() {
assert_eq!(greet("Johnny"), "Hello, Bonny! You've been greeted from Rust!");
}
}
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod greet;
use crate::greet::greet;
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
view raw main.rs hosted with ❤ by GitHub
The three files. Insted of / I had to use . because gist doesn’t allow that, but imagine these are folder notations 😉

Summary

Wow! We got modularization and unit tests working in Rust in like five minutes without any dependencies! That’s pretty awesome, in my opinion. I’m very happy to see nodejs going that way with node:test.

Now my project is ready to begin. Because I’m working on the project with my son, it’s going to take a while, but I’ll update about it. We’re practically building a game that will run on a desktop. Hey, you might see our game on Steam one day 😉

Thanks a lot to Yael Oshri Balla and Yuval Bar Levi for the kind and thorough review.

Sign up to my newsletter to enjoy more content:

0 0 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments