+1

Rust: Zero to production (Phần 1)

1. Cài đặt môi trường

Rust

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Kiểm tra phiên bản

rustc --version

# cargo là package manager của Rust (tương tự npm của Node.js)
cargo --version

cargo-watch

Hot reload tương tự nodemon của Node.js, rất tiện lợi trong quá trình phát triển.

cargo install cargo-watch

Linter

rustup component add clippy

Format code

rustup component add rustfmt

2. Mục tiêu sản phẩm

Chúng ta sẽ xây dựng một dịch vụ gửi email bằng Rust với các chức năng cơ bản sau đây:

  • Người dùng có thể ấn theo dõi tác giả yêu thích.
  • Mỗi khi người viết đăng bài mới, các email thông báo sẽ được gửi đến những người theo dõi của tác giả đó

Được rồi, chúng ta cùng bắt tay vào làm thôi 😄

3. Sign Up A New Subscriber

Chúng ta có user story như sau:

Là một người dùng

Tôi muốn theo dõi các bài viết

Do đó tôi có thể nhận được email thông báo khi có bài viết mới được đăng lên trang blog

Vậy chúng ta sẽ cùng đi thiết kế 1 API /subscriptions nhận email nhập vào từ người dùng để thực hiện tính năng trên.

Những việc cần làm

  • Chọn framework để viết API
  • Viết test
  • Tương tác với database

Chọn framework

Hiện tại, số lượng các web framework của Rust cũng rất đa dạng (actix-web , axum , poem , tide , rocket , ..). Chúng ta sẽ sử dụng actix-web, một framework có tuổi đời lâu nhất cùng với cộng đồng hỗ trợ đông đảo sẽ giúp ích cho việc tìm hiểu và xử lý các lỗi không mong muốn xảy ra.

3.1 API healthcheck

Khởi tạo dự án

cargo new zero2prod

image.png

Cài đặt thư viện

Thêm định nghĩa về các thư viện sẽ dùng vào file Cargo.toml

#! Cargo.toml
# [...]

[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Hoặc cài đặt qua lệnh cargo add

# framework actix-web
cargo add actix-web@4

# Thư viện giúp xử lý các tác vụ bất đồng bộ với Rust (asynchronous runtime)
cargo add tokio@1 --features macros,rt-multi-thread

Hello world

// main.rs
use actix_web::{web, App, HttpRequest, HttpServer, Responder};

async fn greet(req: HttpRequest) -> impl Responder {
    let name = req.match_info().get("name").unwrap_or("World");
    format!("Hello {}!", &name)
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(greet))
            .route("/{name}", web::get().to(greet))
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

Chạy server:

cargo run
# hoặc hot reload với cargo-watch
cargo watch -x run 

Test API:

curl http://localhost:8000

image.png

Server đã trả về "Hello World!" 😄 Sau đây chúng ta sẽ bóc tách để xem ý nghĩa đoạn code như thế nào.

Các thành phần của 1 actix-web http server

Chúng ta sẽ phân tích đoạn code dưới đây:

// src/main.rs
// [...]

#[tokio::main]
async fn main() -> std::io::Result<()> {
	HttpServer::new(|| App::new()
		.route("/", web::get().to(greet))
		.route("/{name}", web::get().to(greet))
	})
	.bind("127.0.0.1:8000")?
	.run()
	.await
}

Server - HttpServer

struct HttpServer có nhiệm vụ xử lý tất cả các tác vụ ở tầng giao thức như:

  • Định nghĩa cổng http được mở
  • Cấu hình TLS/HTTPS
  • Số lượng kết nối tối đa trong 1 thời điểm
  • ...

Application - App

struct App là nơi sẽ định nghĩa các routes, middlewares và xử lý các request .

Endpoint - Route

route method giúp định nghĩa các đầu API và logic xử lý chúng.

Hiện tại ta đang định nghĩa 2 đầu API

  • GET / : trả về Hello, world!
  • GET /{name} : trả về Hello, {name}!, trong đó name do người dùng truyền vào

Request từ client đến sẽ được xử lý và trả về bởi hàm greet(req: HttpRequest)

Runtime - tokio

Ngày trên định nghĩa hàm main, có 1 dòng liên quan đến package tokio là #[tokio::main]. Giờ hãy thử bỏ nó và chạy lại server xem sao.

image.png

main function is not allowed to be async

Thư viện chuẩn của Rust không hỗ trợ hàm main chạy bất đồng bộ. Do đó, chúng ta cần cài thêm thư viện như tokio hay async-std để giúp main có thể xử lý bất đồng bộ.

Triển khai logic api healthcheck

Logic đơn giản là gọi đến api /health_check thì server sẽ trả về 200 OK.

//! src/main.rs

use actix_web::{web, App, HttpResponse, HttpServer, Responder};

async fn health_check() -> impl Responder {
	HttpResponse::Ok() // status 200
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
	HttpServer::new(|| {
			App::new()
				.route("/health_check", web::get().to(health_check))
			})
		.bind("127.0.0.1:8000")?
		.run()
		.await
}

image.png

3.2 Integration Test

Viết test như thế nào ?

Với Rust, chúng ta có thể viết test theo 2 cách phổ biến sau:

  1. Viết trong cùng file luôn với đoạn code muốn test
// code muốn test (logic API, middlewares ...)

#[cfg(test)]
mod tests {
	use super :: *;
	
	// code tests
}

Cách này chúng ta có thể dễ dàng gọi và đến các hàm, module muốn test. Tuy nhiên, với số lượng testcase nhiều sẽ file phình to ra với rất nhiều dòng.

  1. Viết test riêng nằm trong folder tests

Chỉ có thể gọi đến các hàm, module public của source code. Ngược lại, so với cách 1 thì chúng ta có thể chia thành nhiều file test, code sẽ trông gọn gàng hơn.

src/
tests/
Cargo.toml

=> Chúng ta sẽ viết test theo cách 2.

// Tạo folder tests
mkdir -p tests

Cấu trúc lại thư mục dự án

Ta sẽ module hóa lại thành 2 file là lib.rsmain.rs

  • lib.rs sẽ chứa tất cả logic
  • main.rs sẽ chỉ còn là một điểm đầu vào (entrypoint) khi khởi động app
//! lib.rs
use actix_web::{web, App, HttpResponse, HttpServer};

async fn health_check() -> HttpResponse {
	HttpResponse::Ok ().finish()
}

// định nghĩa hàm run với từ khóa `pub` (public)
pub async fn run() -> std::io::Result<()> {
		HttpServer:: new(|| {
			App:: new()
				.route("/health_check", web::get().to(health_check))
			})
			.bind("127.0.0.1:8000")?
			.run()
			.await
}
//! main.rs

// import hàm run
use zero2prod::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
	run().await
}

Hàm run bắt buộc phải định nghĩa với từ khóa pub để có thể import từ file khác.

=> Báo lỗi nếu không định nghĩa run là public image.png

Ngoài ra, chúng ta cũng cần bổ sung file Cargo.toml như sau:

# ...

[lib]
path = "src/lib.rs"

[[bin]]
path = "src/main.rs"
name = "zero2prod"

# ...

Testcase đầu tiên

Cài đặt package reqwest (khá giống axios ) dùng để gửi Http request.

cargo add reqwest
// tests/health_check.rs
use zero2prod::run;

#[tokio::test]
async fn health_check_works() {
    spawn_app().await.expect("Failed to spawn our app.");

    let client = reqwest::Client::new();
    let response = client.get("http://127.0.0.1:8000/health_check").send().await.expect("Failed to execute request.");
    
    // test trả về status 200
    assert!(response.status().is_success());
    
    // test trả về body length = 0
    assert_eq!(Some(0), response.content_length());
}

async fn spawn_app() -> std::io::Result<()> {
    run().await
}

Chạy test với cargo test

image.png

Chúng ta gặp tình trạng test chạy vô hạn và không dừng lại. Nguyên do hàm spawn_app() gọi run() trong khi đó nó cũng đang await để lắng nghe cổng 😦

=> Chúng ta cần sửa lại logic await ở hàm run() cùng các file liên quan.

//! src/lib.rs
use actix_web::dev::Server;
use actix_web::{web, App, HttpResponse, HttpServer};

async fn health_check() -> HttpResponse {
    HttpResponse::Ok().finish()
}

// return Server thay vì std::io:Result như cũ 
// định nghĩa hàm run cũng bỏ từ khóa async
pub fn run() -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind("127.0.0.1:8000")?
        .run();
    // bỏ await
    Ok(server)
}
//! tests/health_check.rs

use zero2prod::run;

#[tokio::test]
async fn health_check_works() {
    // bỏ await, expect là spawn_app không còn là hàm bất đồng bộ
    spawn_app();

    let client = reqwest::Client::new();
    let response = client.get("http://127.0.0.1:8000/health_check")
                            .send()
                            .await
                            .expect("Failed to execute request.");

    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

fn spawn_app() {
    let server = run().expect("Failed to bind address");
    
    // Khởi động server chạy nền
    let _ = tokio::spawn(server);
}

chạy test => passed 😄

image.png

Cải thiện code

Hàm spawn_app() khi gọi gọi sẽ sử dụng cổng 8000 để hoạt động. Lỗi xung đột sẽ xảy ra khi chạy test trong lúc app cũng đang được chạy.

image.png

Giải pháp xử lý ở đây là ta sẽ thêm tham số để cấu hình cổng được mở của server. Khi chạy test sẽ chọn một cổng ngẫu nhiên nào đó.

//! src/lib.rs

// [...]

// truyền thêm cổng muốn mở cho server thay vì fix cứng như trước
pub fn run(address: &str) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind(address)?
        .run();
    Ok(server)
}
//! src/main.rs

use zero2prod::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    run("127.0.0.1:8000")?.await
}

Chọn port 0 cho testcase

fn spawn_app() {
    let server = run("127.0.0.1:0").expect("Failed to bind address");
    let _ = tokio::spawn(server);
}

Chạy test, tuy nhiên xuất hiện báo lỗi.

image.png

request vẫn được gọi tới cổng 8000 thay vì cổng 0 => lỗi. Phương pháp truyền tham cổng có vẻ không khả thi.

Sử dụng listener

Ngoài cách sử dụng hàm bind và truyền vào cổng cần mở, chúng ta có thể sử dụng TcpListener. Với listener chúng ta có thể chủ động mở cổng trước thay vì truyền tham số vào hàm run như cách cũ.

//! src/lib.rs
use actix_web::dev::Server;
use actix_web::{web, App, HttpResponse, HttpServer};
use std::net::TcpListener;

// [...]

pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .listen(listener)?
        .run();
    Ok(server)
}
//! src/main.rs

use std::net::TcpListener;
use zero2prod::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8000")?;
    run(listener)?.await
}

//! tests/health_check.rs
use std::net::TcpListener;
use zero2prod::run;

#[tokio::test]
async fn health_check_works() {
 
    let address = spawn_app();
    let client = reqwest::Client::new();
    
    let response = client
        // Use the returned application address
        .get(&format!("{}/health_check", &address))
        .send()
        .await
        .expect("Failed to execute request.");
    // Assert
    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

fn spawn_app() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");

    let port = listener.local_addr().unwrap().port();
    let server = run(listener).expect("Failed to bind address");
    let _ = tokio::spawn(server);
     
    format!("http://127.0.0.1:{}", port)
}

chạy test => passed

image

3.3 Quay lại mục tiêu ban đầu

Vậy là qua các phần trên, chúng ta đã hoàn chỉnh 1 đầu API và testcase của nó. Nhưng đó cũng chỉ là 1 API health_check rất đơn giản nhằm mục đích làm quen với Rust và framework actix-web. Bây giờ, chúng ta cùng tập trung lại mục tiêu của dự án.

Là một người dùng

Tôi muốn theo dõi các bài viết

Do đó tôi có thể nhận được email thông báo khi có bài viết mới được đăng lên trang blog

Xử lý HTML form

Người dùng sẽ gửi tên và email lên server, ở đây chúng ta sẽ có 2 kịch bản chủ yếu

  • Tên và email hợp lệ => trả về 200 OK
  • Tên hoặc email không hợp lệ => trả về 400 BAD REQUEST

Triển khai testcase

Chúng ta sẽ biểu diễn cách TH qua testcase trước.

//! tests/health_check.rs
use std::net::TcpListener;

// [...]

#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
    let app_address = spawn_app();
    let client = reqwest::Client::new();
    
    let body = "name=le%20g&email=le%40gmail.com";
    let response = client
        .post(&format!("{}/subscriptions", &app_address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to execute request.");
    
    assert_eq!(200, response.status().as_u16());
}

#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
    
    let app_address = spawn_app();
    let client = reqwest::Client::new();
    let test_cases = vec![
        ("name=le%20guin", "missing the email"),
        ("email=ursula_le_guin%40gmail.com", "missing the name"),
        ("", "missing both name and email"),
    ];
    for (invalid_body, error_message) in test_cases {
        
        let response = client
            .post(&format!("{}/subscriptions", &app_address))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(invalid_body)
            .send()
            .await
            .expect("Failed to execute request.");
        
        assert_eq!(
            400,
            response.status().as_u16(),
            // Additional customised error message on test failure
            "The API did not fail with 400 Bad Request when the payload was {}.",
            error_message
        );
    }
}

Do chưa có logic API nên kết quả khi chạy các testcase tạm thời sẽ fail.

Định nghĩa API /subscriptions

//! src/lib.rs

// [...]

// Đơn giản nhất chúng ta để hàm trả về status 200
async fn subscribe() -> HttpResponse {
    HttpResponse::Ok().finish()
}

pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| {
        App::new()
            .route("/health_check", web::get().to(health_check))
            // POST /subscriptions
            .route("/subscriptions", web::post().to(subscribe)) 
    })
    .listen(listener)?
    .run();
    Ok(server)
}

Định nghĩa cấu trúc formData

Cài đặt thêm thư viện serde, giúp chuyển đổi các cấu trúc dữ liệu với nhau.

// [...]

[dependencies]
serde = { version = "1", features = ["derive"]}
//! src/lib.rs

// [...]

// định nghĩa body data nhận từ client sẽ gồm email và name
#[derive(serde::Deserialize)]
struct FormData {
    email: String,
    name: String,
}

async fn subscribe(_form: web::Form<FormData>) -> HttpResponse {
    HttpResponse::Ok().finish()
}

Database

Hệ quản trị cơ sở dữ liệu chúng ta sử dụng sẽ là PostgresSQL cùng với thư viện sqlx để kết nối và tương tác với PostgresSQL.

# Cargo.toml

[dependencies]
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] }
# [...]

cấu hình database url và tạo bảng subscriptions.

# .env

DATABASE_URL=postgres://postgress:password@127.0.0.1:5432/newsletter
CREATE TABLE subscriptions(
    id uuid NOT NULL,
    PRIMARY KEY (id),
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    subscribed_at timestamptz NOT NULL
);

Cấu trúc lại thư mục dự án

Hiện tại tất cả logic API đều nằm tại file lib.rs, chúng ta cần mô đun hóa, cấu trúc lại để mọi thứ trông sáng sủa hơn.

src/
    configuration.rs
    lib.rs
    main.rs
    routes/
        mod.rs
        health_check.rs
        subscriptions.rs
        startup.rs
tests/
Cargo.toml
configuration.yaml
  • function run sẽ nằm ở file startup.rs
  • các API được chia thành 2 file
  • mod.rs định nghĩa các module API
  • configuration.rs xử các config về database
  • configuration.yaml chứa thông tin về database(port, address, database name)
//! src/routes/mod.rs

mod health_check;
mod subscriptions;

pub use health_check::*;
pub use subscriptions::*;
//! src/lib.rs

pub mod configuration;
pub mod routes;
pub mod startup;

Cấu hình database

File configuration.rs sẽ có nhiệm vụ đọc các thông tin liên quan đến database từ file configuration.yaml.

cài đặt thêm thư viện config để làm việc với file yaml

cargo add config
// src/configuration.rs 

#[derive(serde::Deserialize)]
pub struct Settings {
    pub database: DatabaseSettings,
    pub application_port: u16,
}

#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
    pub username: String,
    pub password: String,
    pub port: u16,
    pub host: String,
    pub database_name: String,
}

pub fn get_configuration() -> Result<Settings, config::ConfigError> {
    let settings = config::Config::builder()
        .add_source(config::File::new(
            "configuration.yaml",
            config::FileFormat::Yaml,
        ))
        .build()?;
  
    settings.try_deserialize::<Settings>()
}
# configuration.yaml

application_port: 8000
database:
  host: "127.0.0.1"
  port: 5432
  username: ""
  password: ""
  database_name: "newsletter"
//! src/main.rs

use std::net::TcpListener;
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // Báo lỗi nếu không thể đọc được từ file config
    let configuration = get_configuration().expect("Failed to read configuration.");
    // Bây giờ cổng được mở sẽ được quy định ở file config luôn
    let address = format!("127.0.0.1:{}", configuration.application_port);
    let listener = TcpListener::bind(address)?;
    run(listener)?.await
}

Kết nối với database

// src/configuration.rs
// [...]

impl DatabaseSettings {
    pub fn connection_string(&self) -> String {
        format!(
            "postgres://{}:{}@{}:{}/{}",
            self.username, self.password, self.host, self.port, self.database_name
        )
    }
}

Triển khai testcase với logic kết nối đến database (TH lý tưởng)

//! tests/health_check.rs

use sqlx::{Connection, PgConnection};
use std::net::TcpListener;
use zero2prod::configuration::get_configuration;
use zero2prod::run;

#[tokio::test]
async fn health_check_works() {
    let app_address = spawn_app();
    let configuration = get_configuration().expect("Failed to read configuration");
    let connection_string = configuration.database.connection_string();
    
    // Báo lỗi nếu kết nối đến database thất bại
    let connection = PgConnection::connect(&connection_string)
        .await
        .expect("Failed to connect to Postgres.");
    let client = reqwest::Client::new();

    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    let response = client
        .post(&format!("{}/subscriptions", &app_address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to execute request.");

    assert_eq!(200, response.status().as_u16());
}

// [...]

Application State

Từ ban đầu đến hiện tại, ứng dụng hoàn toàn không lưu trữ trạng thái. Giờ đây, khi cần duy trì kết nối đến database khi chạy server => ta cần sửa đổi 1 chút hàm run

//! src/startup.rs

use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use sqlx::PgPool;
use std::net::TcpListener;

pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> {
    let db_pool = web::Data::new(db_pool);
    let server = HttpServer::new(move || {
        App::new()
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(db_pool.clone())
    })
    .listen(listener)?
    .run();
    Ok(server)
}


//! src/main.rs

use sqlx::PgPool;
use std::net::TcpListener;
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let configuration = get_configuration().expect("Failed to read configuration.");
    // Renamed!
    let connection_pool = PgPool::connect(&configuration.database.connection_string())
        .await
        .expect("Failed to connect to Postgres.");
    let address = format!("127.0.0.1:{}", configuration.application_port);
    let listener = TcpListener::bind(address)?;
    run(listener, connection_pool)?.await
}


SQL insert

Triển khai logic lưu thông tin của người dùng vào database khi ấn theo dõi.

Cài đặt thêm thư viện tạo uuid và chrono quản lý thời gian

[dependencies]
# [...]
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4.22", default-features = false , features = ["clock"] }
// !src/routes/subscription.rs

use actix_web::{web, HttpResponse};
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;

#[derive(serde::Deserialize)]
pub struct FormData {
    email: String,
    name: String,
}

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    match sqlx::query!(
        r#"
    INSERT INTO subscriptions (id, email, name, subscribed_at)
    VALUES ($1, $2, $3, $4)
            "#,
        Uuid::new_v4(),
        form.email,
        form.name,
        Utc::now()
    )
    .execute(pool.as_ref())
    .await
    {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(e) => {
            println!("Failed to execute query: {}", e);
            HttpResponse::InternalServerError().finish()
        }
    }
}

Hoàn thiện testcase

// ! tests/health_check.rs

use sqlx::{Connection, Executor, PgConnection, PgPool};
use std::net::TcpListener;
use uuid::Uuid;
use zero2prod::configuration::{get_configuration, DatabaseSettings};
use zero2prod::startup::run;

pub struct TestApp {
    pub address: String,
    pub db_pool: PgPool,
}

async fn spawn_app() -> TestApp {
    let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
    // We retrieve the port assigned to us by the OS
    let port = listener.local_addr().unwrap().port();
    let address = format!("http://127.0.0.1:{}", port);

    let mut configuration = get_configuration().expect("Failed to read configuration.");
    configuration.database.database_name = Uuid::new_v4().to_string();
    let connection_pool = configure_database(&configuration.database).await;

    let server = run(listener, connection_pool.clone()).expect("Failed to bind address");
    let _ = tokio::spawn(server);
    TestApp {
        address,
        db_pool: connection_pool,
    }
}

pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
    // Create database
    let mut connection = PgConnection::connect(&config.connection_string_without_db())
        .await
        .expect("Failed to connect to Postgres");
    connection
        .execute(&*format!(r#"CREATE DATABASE "{}";"#, config.database_name))
        .await
        .expect("Failed to create database.");

    // Migrate database
    let connection_pool = PgPool::connect(&config.connection_string())
        .await
        .expect("Failed to connect to Postgres.");
    sqlx::migrate!("./migrations")
        .run(&connection_pool)
        .await
        .expect("Failed to migrate the database");

    connection_pool
}

#[tokio::test]
async fn health_check_works() {
    // Arrange
    let app = spawn_app().await;
    let client = reqwest::Client::new();

    // Act
    let response = client
        // Use the returned application address
        .get(&format!("{}/health_check", &app.address))
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
    // Arrange
    let app = spawn_app().await;
    let client = reqwest::Client::new();
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";

    // Act
    let response = client
        .post(&format!("{}/subscriptions", &app.address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(200, response.status().as_u16());

    let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
        .fetch_one(&app.db_pool)
        .await
        .expect("Failed to fetch saved subscription.");

    assert_eq!(saved.email, "ursula_le_guin@gmail.com");
    assert_eq!(saved.name, "le guin");
}

#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
    // Arrange
    let app = spawn_app().await;
    let client = reqwest::Client::new();
    let test_cases = vec![
        ("name=le%20guin", "missing the email"),
        ("email=ursula_le_guin%40gmail.com", "missing the name"),
        ("", "missing both name and email"),
    ];

    for (invalid_body, error_message) in test_cases {
        // Act
        let response = client
            .post(&format!("{}/subscriptions", &app.address))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(invalid_body)
            .send()
            .await
            .expect("Failed to execute request.");

        // Assert
        assert_eq!(
            400,
            response.status().as_u16(),
            // Additional customised error message on test failure
            "The API did not fail with 400 Bad Request when the payload was {}.",
            error_message
        );
    }
}

Tài liệu tham khảo

https://github.com/LukeMathWalker/zero-to-production


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí