DEV Community

loading...
Cover image for Rocket Tutorial 03 part II: Proper testing.

Rocket Tutorial 03 part II: Proper testing.

davidedelpapa profile image Davide Del Papa ・6 min read

Rocket Tutorial 03 part II: Proper testing.

[Photo by Kyle Glenn on Unsplash, modified (cropped)]

We will continue to build a CRUD API with Rocket.

This is the part II of the tutorial 3. It is in between tutorial 3 and tutorial 4. The reason is... testing.
We have to do bl**dy testing, because... a programmer ought to do what a programmer ought to do, right?

So, just skip this tutorial and pass to tutorial 4 if you don't feel like it, with the promise you will at least clone the repo and have the tests done.

This time I wrote for you a lot of code, but generally I will gloss over it, and chit chat on the choices made. In fact, I think it more profitable to you that I explain the reasons why, instead of going over every little detail of the code.

Indeed, the code for this tutorial can be found in this repository: github.com/davidedelpapa/rocket-tut, and has been tagged for your convenience:

git clone https://github.com/davidedelpapa/rocket-tut.git
cd rocket-tut
git checkout tags/tut3pII
Enter fullscreen mode Exit fullscreen mode

Introduction

I understand that testing is on of the most boring activities that we as programmers could do. Thus, if you are not interested to know just now how to test the API you are free to skip this.

However, I do recommend to check it out later, because ensuring proper testing of each route must be a duty in a real world project.

Last time we tested things out with curl (and jq). This time we will use Rust proper tools

Let's update our tests/basic_test.rs:

use lazy_static;
use rocket::http::{ContentType, Status};
use rocket_tut::data::db::ResponseUser;
Enter fullscreen mode Exit fullscreen mode

We will make use of the ResponseUser we created in order to parse also the API response for the tests.

ping_test() has not changed, it didn't have to. Instead, let's see how to update user_list_rt_test():

#[test]
fn user_list_rt_test(){
    let client = common::setup();
    let mut response = client.get("/api/users").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    let mut response_body = response.body_string().unwrap();
    response_body.retain(|c| !c.is_numeric());
    assert_eq!(response_body, "[]");
}
Enter fullscreen mode Exit fullscreen mode

We eliminated the useless scaffold API response:

assert_eq!(response.body_string(), Some("{\"status\":\"Success\",\"message\":\"List of users\"}".into()));
Enter fullscreen mode Exit fullscreen mode

In its place we added:

let mut response_body = response.body_string().unwrap();
Enter fullscreen mode Exit fullscreen mode

That gets the response's body for analysis.
Then, retain() parses each char in a String retaining only those specified in the closure in the predicate.
In this case whatever thing is not numeric (!c.is_numeric()).
What is left is a "[]".

Independent tests

You may ask why did not we check if the response in the above is simply equal to "[0]".

In reality we do not know if it will be 0 or any other number: in fact, each test is run in a different thread, and by the time the thread that tests user_list_rt_test() is executed, another test that inserts a user might have been already executed, and we have now already a user!

Indeed, we have to make each test totally independent. Any other solution is less than adequate. In fact one may think to test all endpoints in one test; but this renders difficult to know which endpoints fails after a change. In other words, we could not make sure that there has not been a regression, not easily at least.

Moreover, we could devise a way to make the tests execute on only one thread, and sequentially. However, testing the multi-thread response is a plus, because we might discover more easily the presence of any race condition. Besides, this ways tests are done faster. Sadly, you might discover very soon how long testing takes (wait for the end of this tutorial!).

In order to keep each test independent, we will be inserting a new user for each of the test of the endpoints, and test specifically against that user's info.

Moreover, we need a new dev-dependency to check the response more easily: we will use serde_json:

cargo add serde_json --dev
Enter fullscreen mode Exit fullscreen mode

Now we can import it at the beginning of tests/basic_test.rs:

use serde_json;
Enter fullscreen mode Exit fullscreen mode

And now we will use it inside the next route:

#[test]
fn new_user_rt_test(){
    let client = common::setup();
    let mut response = client.post("/api/users")
        .header(ContentType::JSON)
        .body(r##"{
            "name": "John Doe",
            "email": "j.doe@m.com",
            "password": "123456"
        }"##)
        .dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    let response_body = response.body_string().expect("Response Body");
    let user: ResponseUser = serde_json::from_str(&response_body.as_str()).expect("Valid User Response");
    assert_eq!(user.name, "John Doe");
    assert_eq!(user.email, "j.doe@m.com");
}
Enter fullscreen mode Exit fullscreen mode

We send the info in the body in a rough way: of course we could construct a InsertableUser, then pass it to the body with json! for example. But using a string literal is acceptable and quick.
Instead, we parse the answer with serde_json to a ResponseUser, and we test the name and email of the newly created user.

Insert a new user, check only on that user

For the next route, as we have said, we need to first insert a user, then we check for its info:

#[test]
fn info_user_rt_test(){
    let client = common::setup();
    let mut response_new_user = client.post("/api/users")
        .header(ContentType::JSON)
        .body(r##"{
            "name": "Jane Doe",
            "email": "jane.doe@m.com",
            "password": "123456"
        }"##)
        .dispatch();
    let response_body = response_new_user.body_string().expect("Response Body");
    let user_new: ResponseUser = serde_json::from_str(&response_body.as_str()).expect("Valid User Response");
    let id = user_new.id;
    let mut response = client.get(format!("/api/users/{}", id)).dispatch();
    let response_body = response.body_string().expect("Response Body");
    let user: ResponseUser = serde_json::from_str(&response_body.as_str()).expect("Valid User Response");
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    assert_eq!(user.name, "Jane Doe");
    assert_eq!(user.email, "jane.doe@m.com");
    assert_eq!(user.id, id);
}
Enter fullscreen mode Exit fullscreen mode

As you can see: first we insert a new user, then we extract its id from the response, in order to insert it in the next request.

At this point we can get info on the user, and check against its id, name and email.

Similarly, in the following routes: We insert a new user, extract its id, test if the route works.

Of course, in order to delete the user we need to provide its password.

The same thing applies when we want to change the password. Actually there we need to provide also a new password.

You get the knack of it, go and check out the code, and if something's unclear, post a comment, and we'll discuss it together.

Test for fails

You might think that this is all there is to test. Well, no, actually it is not: checking that a route returns the expected result is not sufficient.

Checking the correctness of the response is only half of the basic testing to be done. We should check also that the API returns errors any time it has to. Besides, even when returning errors, we must make sure that no undefined behavior happens, and that a user that wants to interface with our API knows how to detect and error. Imagine that our API returns a 400 (Success) code when instead an error happened...

For that reason I created another suite of tests, a "negative" one, which tests out for failures. You can find it inside tests/failures_test.rs.

I'm not going to comment any of its code here.
Besides, I wrote some comments that I hope will be useful to understand the code directly.

The only things of notice are:

  1. The suite needs to provide explicitly failing cases and use sometimes assert_ne! macro;
  2. there are many fails we can cause to happen, but apart from checking an explicitly nonexistent route as a generic_fail, we kept on trying to make the API fail the most common ways it can fail: missing or malforming something in the requests, in order to trigger explicitly the ApiResponse::err() we implemented in src/routes/user.rs
  3. we make the second ranked GET fail as well, but we made sure that even in failing the API would call the right route (the second one). Check the code to know how we did it.

We can now run cargo test and proudly see all tests passing, if everything went smooth.

Conclusions

It has been a lot of testing, but I hope you understand that this is a very useful thing to do, albeit sometimes it is considered "boring."

Next time we will introduce a database to secure our data.

Stay tuned!

Discussion

pic
Editor guide