Javier

04 - Basic CRUD with rust using tide - tests improvements

Hi all again, in the last note we refactor the code to persist the information in a relational database, postgresql in our case.

Let's start where we left last time ([ci bonus] (https://collectednotes.com/javier/03-basic-crud-with-rust-using-tide-move-to-db#ci-bonus)) and tie those loose ends in the ci.

Improving tests

First, one thing to improve in our tests is start using surf as client since is the client recommended by http-rs.

So, let's add surf as dev-dependency

[dev-dependencies]
surf = "2.1.0"

And then in our code, let's use surf as client en each test

(...)
    let res = surf::Client::with_http_client(app)
        .get("https://example.com/dinos")
        .await?;
    assert_eq!(200, res.status());

(...)

    let mut res = surf::Client::with_http_client(app)
        .post("https://example.com/dinos")
        .body(serde_json::to_string(&dino)?)
        .await?;

Great, let's run the tests...

❯ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.20s
     Running target/debug/deps/tide_basic_crud-1a926f88350611fd

running 5 tests
test tests::list_dinos ... ok
test tests::create_dino ... ok
test tests::delete_dino ... ok
test tests::get_dino ... ok
test tests::update_dino ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Awesome! But at this point we are only asserting the status code. Let's check now also the returned payload, for that we will use the crate assert-json-diff that add two macros:

  • assert_json_eq : macro used to compare two JSON values for an exact match.
  • assert_json_include : macro used to compare two JSON values for an inclusive match.

For example, add this lines to the get_dino test

let d: Dino = res.body_json().await?;
assert_json_eq!(dino, d);

Let's run the tests again ones we add the json asserts...

❯ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 1.44s
     Running target/debug/deps/tide_basic_crud-1a926f88350611fd

running 5 tests
test tests::list_dinos ... ok
test tests::create_dino ... ok
test tests::delete_dino ... ok
test tests::get_dino ... ok
test tests::update_dino ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Great! now we are also validating the returned payload.

We have a couple more of TODOs before finish the improvements. First, we need to clear the dinos table before run each test since we always want to create an isolated test case. To accomplish that, let's create a module (mod) in our main file for the tests and add a helper function to clear the dinos table.

#[cfg(test)]
mod tests {
    use super::*;
    use lazy_static::lazy_static;
    use sqlx::query;

    async fn clear_dinos() -> Result<(),Box<dyn std::error::Error>> {
        let db_pool = make_db_pool(&DB_URL).await;

        sqlx::query("DELETE FROM dinos").execute(&db_pool).await?;
        Ok(())
    }
(...)

And in each test we need to run clear_dinos before make any change/request.

    #[async_std::test]
    async fn create_dino() -> tide::Result<()> {
        dotenv::dotenv().ok();
        clear_dinos().await.expect("Failed to clear the dinos table");
(...)

Great, so one more task to go. We need to set the ci (gh actions) to run the tests. For that we set a new block in ci.yml to run those tests

    - name: Run test
      run: cargo test
      env:
          DATABASE_URL: postgres://postgres:postgres@localhost:${{ job.services.postgres.ports[5432] }}/tide

And we are ready to create a new PR with this test improvements and check if all the steps works as expected

image

Nice! we now have the ci configured.

Beyond the happy path

Until now our test only check the happy path and we are not testing errors. Let's add some basic test cases for cover those

  • Create a duplicate dino with an existing key, should return 409

We need to handler the insert error and return the appropriated error since using the ? here will bubble the error to the caller.

        let row : Dino =  match query_as!(
            Dino,
            r#"
            INSERT INTO dinos (id, name, weight, diet) VALUES
            ($1, $2, $3, $4) returning id, name, weight, diet
            "#,
            dino.id,
            dino.name,
            dino.weight,
            dino.diet
        )
        .fetch_one(&db_pool)
        .await {
            Ok( r) => r,
            Err( e ) => {
                // TODO: we may want to cast the error here.
                let err = Error::new(409,e);
                return Err(err);
            }
        };

(...)
  • Get/Delete/Update dino with a non existing key, should return 404

In this cases we only need to send an invalid key ( e.g. a new one and should works )

        let  res = surf::Client::with_http_client(app)
            .delete(format!("https://example.com/dinos/{}", &Uuid::new_v4()))
            .await?;

        assert_eq!(404, res.status());

Upgrade bonus

Also, this week a new version of tide was released with a new way to start the servers

Tide v0.15.0 introduces a new way to start servers: Server::bind. This enables separating "open the socket" from "start accepting connections" which Server::listen does for you in a single call.

Let's update our code to use this new version, first the deps in cargo

[dependencies]
tide = "0.15.0"
async-std = { version = "1.7.0", features = ["attributes"] }

And in our code the main function now looks like this

#[async_std::main]
async fn main() {
    dotenv::dotenv().ok();

    tide::log::start();
    let db_url = std::env::var("DATABASE_URL").unwrap();
    let db_pool = make_db_pool(&db_url).await;

    let app = server(db_pool).await;
    let mut listener = app.bind("127.0.0.1:8080").await.expect("can't bind the port");

    for info in listener.info().iter() {
        println!("Server listening on {}", info);
    }
    listener.accept().await.unwrap();
}

That's all for today, I was planned to add the implementation of tera as render engine but will cover that in the next note and keep this one focused in tests.

As always, I write this as a learning journal and there could be another more elegant and correct way to do it and any feedback is welcome.

Thanks!