Skip to content

Commit

Permalink
refactor tests to re-use application build process
Browse files Browse the repository at this point in the history
  • Loading branch information
alexsavio committed Apr 3, 2024
1 parent 8a8f9d2 commit c80bbab
Show file tree
Hide file tree
Showing 8 changed files with 267 additions and 229 deletions.
8 changes: 4 additions & 4 deletions src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@ use serde_aux::field_attributes::deserialize_number_from_string;
use crate::domain::SubscriberEmail;


#[derive(serde::Deserialize)]
#[derive(Clone, serde::Deserialize)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
pub email_client: EmailClientSettings,
}

#[derive(serde::Deserialize)]
#[derive(Clone, serde::Deserialize)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
}

#[derive(serde::Deserialize)]
#[derive(Clone, serde::Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: Secret<String>,
Expand All @@ -30,7 +30,7 @@ pub struct DatabaseSettings {
pub require_ssl: bool,
}

#[derive(serde::Deserialize)]
#[derive(Clone, serde::Deserialize)]
pub struct EmailClientSettings {
pub base_url: String,
pub sender_email: String,
Expand Down
28 changes: 3 additions & 25 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
use std::net::TcpListener;
use sqlx::postgres::PgPoolOptions;
use zero2prod::startup::run;
use zero2prod::startup::Application;
use zero2prod::configuration::get_configuration;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
use zero2prod::email_client::EmailClient;


#[tokio::main]
Expand All @@ -14,27 +11,8 @@ async fn main() -> Result<(), std::io::Error> {

// setup database connection
let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool = PgPoolOptions::new()
.connect_lazy_with(configuration.database.with_db()
);
let sender_email = configuration.email_client.sender()
.expect("Invalid sender email address.");
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout,
);
let address = format!(
"{}:{}",
configuration.application.host,
configuration.application.port,
);
let listener = TcpListener::bind(address)?;

// run the server
run(listener, connection_pool, email_client)?.await?;
let server = Application::build(configuration).await?;
server.run_until_stopped().await?;
Ok(())
}

76 changes: 71 additions & 5 deletions src/startup.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,79 @@
use std::net::TcpListener;
use actix_web::{web, App, HttpServer};
use crate::configuration::{DatabaseSettings, Settings};
use crate::email_client::EmailClient;
use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::net::TcpListener;
use tracing_actix_web::TracingLogger;
use crate::routes::{health_check, subscribe};
use crate::email_client::EmailClient;

pub struct Application {
port: u16,
server: Server,
}

impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
let connection_pool = get_connection_pool(&configuration.database);
let sender_email = configuration
.email_client
.sender()
.expect("Invalid sender email address.");
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout,
);
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port,
);
let listener = TcpListener::bind(address)?;
let port = listener.local_addr().unwrap().port();
let server = run(listener, connection_pool, email_client)?;
Ok(Self { port, server })
}

pub fn port(&self) -> u16 {
self.port
}

pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
self.server.await
}
}

pub async fn build(configuration: Settings) -> Result<Server, std::io::Error> {
let connection_pool = get_connection_pool(&configuration.database);
let sender_email = configuration
.email_client
.sender()
.expect("Invalid sender email address.");
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout,
);
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port,
);
let listener = TcpListener::bind(address)?;

// run the server
run(listener, connection_pool, email_client)
}

pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}

pub fn run(
fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
Expand Down
20 changes: 20 additions & 0 deletions tests/api/health_check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use crate::helpers::spawn_app;

#[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());
}
73 changes: 73 additions & 0 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use sqlx::postgres::PgPoolOptions;
use uuid::Uuid;
use zero2prod::configuration::{DatabaseSettings, get_configuration};
use zero2prod::startup::{Application, get_connection_pool};
use zero2prod::telemetry::{get_subscriber, init_subscriber};

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

static TRACING: Lazy<()> = Lazy::new(|| {
let default_filter_level = "info".to_string();
let subscriber_name = "test".to_string();
if std::env::var("TEST_LOG").is_ok() {
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
init_subscriber(subscriber);
} else {
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);
init_subscriber(subscriber);
};
});

// Launch our application in the background
pub async fn spawn_app() -> TestApp {
// The first time `initialize` is invoked the code in `TRACING` is executed.
// All other invocations will instead skip execution.
Lazy::force(&TRACING);

let configuration = {
let mut c = get_configuration().expect("Failed to read configuration.");
// Use a different database for each test case
c.database.name = Uuid::new_v4().to_string();
// Use a random OS-assigned port
c.application.port = 0;
c
};

// Create and migrate the database
configure_database(&configuration.database).await;

// Launch the application as a background task
let application = Application::build(configuration.clone()).await.expect("Failed to build application.");
let address = format!("http://127.0.0.1:{}", application.port());
let _ = tokio::spawn(application.run_until_stopped());
TestApp {
address,
db_pool: get_connection_pool(&configuration.database),
}
}

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

// Migrate database
let connection_pool = PgPoolOptions::new().connect_lazy_with(config.with_db());
sqlx::migrate!("./migrations")
.run(&connection_pool)
.await
.expect("Failed to migrate the database");

connection_pool
}
3 changes: 3 additions & 0 deletions tests/api/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mod helpers;
mod health_check;
mod subscriptions;
93 changes: 93 additions & 0 deletions tests/api/subscriptions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
use crate::helpers::spawn_app;

#[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, "[email protected]");
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
);
}
}


#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
("name=Ursula&email=", "empty email"),
("name=&email=", "empty name and email"),
];

for (body, description) in test_cases {
// 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!(
400,
response.status().as_u16(),
"The API returned a 400 when the payload was {}.",
description
);
}
}
Loading

0 comments on commit c80bbab

Please sign in to comment.