Skip to content

Commit

Permalink
test: isolated environments for integration testing
Browse files Browse the repository at this point in the history
It allows integration test to run a custom env (with a custom
configuration) and totally isolated from other tests.

The test env used a socket address assigned from the OS (free port).

You can do that by setting the port number to 0 in the config.toml file:

```
[net]
port = 0
```
  • Loading branch information
josecelano committed Apr 29, 2023
1 parent 2211871 commit 6d5e002
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 16 deletions.
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ NCCA
nilm
nocapture
Oberhachingerstr
oneshot
ppassword
reqwest
Roadmap
Expand Down
5 changes: 4 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ pub async fn run(configuration: Configuration) -> Running {

let running_server = server.run();

info!("Listening on http://{}", socket_address);
let starting_message = format!("Listening on http://{}", socket_address);
info!("{}", starting_message);
// Logging could be disabled or redirected to file. So print to stdout too.
println!("{}", starting_message);

Running {
api_server: running_server,
Expand Down
2 changes: 1 addition & 1 deletion src/bootstrap/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub fn setup() {

fn config_level_or_default(log_level: &Option<String>) -> LevelFilter {
match log_level {
None => log::LevelFilter::Info,
None => log::LevelFilter::Warn,
Some(level) => LevelFilter::from_str(level).unwrap(),
}
}
Expand Down
27 changes: 17 additions & 10 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub struct Tracker {
pub token_valid_seconds: u64,
}

/// Port 0 means that the OS will choose a random free port.
pub const FREE_PORT: u16 = 0;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Network {
pub port: u16,
Expand Down Expand Up @@ -77,14 +80,9 @@ pub struct TorrustConfig {
pub mail: Mail,
}

#[derive(Debug)]
pub struct Configuration {
pub settings: RwLock<TorrustConfig>,
}

impl Configuration {
pub fn default() -> Configuration {
let torrust_config = TorrustConfig {
impl TorrustConfig {
pub fn default() -> Self {
Self {
website: Website {
name: "Torrust".to_string(),
},
Expand Down Expand Up @@ -118,10 +116,19 @@ impl Configuration {
server: "".to_string(),
port: 25,
},
};
}
}
}

#[derive(Debug)]
pub struct Configuration {
pub settings: RwLock<TorrustConfig>,
}

impl Configuration {
pub fn default() -> Configuration {
Configuration {
settings: RwLock::new(torrust_config),
settings: RwLock::new(TorrustConfig::default()),
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/tracker.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::sync::Arc;

use log::info;
use log::{error, info};
use serde::{Deserialize, Serialize};

use crate::config::Configuration;
Expand Down Expand Up @@ -185,9 +185,15 @@ impl TrackerService {

for torrent in torrents {
info!("Updating torrent {} ...", torrent.torrent_id);
let _ = self
let ret = self
.update_torrent_tracker_stats(torrent.torrent_id, &torrent.info_hash)
.await;
if let Some(err) = ret.err() {
error!(
"Error updating torrent tracker stats for torrent {}: {:?}",
torrent.torrent_id, err
);
}
}

Ok(())
Expand Down
1 change: 1 addition & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pub mod client;
pub mod connection_info;
pub mod contexts;
pub mod http;
pub mod random;
pub mod responses;
10 changes: 10 additions & 0 deletions tests/common/random.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! Random data generators for testing.
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};

/// Returns a random alphanumeric string of a certain size.
///
/// It is useful for generating random names, IDs, etc for testing.
pub fn string(size: usize) -> String {
thread_rng().sample_iter(&Alphanumeric).take(size).map(char::from).collect()
}
111 changes: 111 additions & 0 deletions tests/integration/app_starter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use std::net::SocketAddr;

use log::info;
use tokio::sync::{oneshot, RwLock};
use torrust_index_backend::app;
use torrust_index_backend::config::{Configuration, TorrustConfig};

/// It launches the app and provides a way to stop it.
pub struct AppStarter {
configuration: TorrustConfig,
/// The application binary state (started or not):
/// - `None`: if the app is not started,
/// - `RunningState`: if the app was started.
running_state: Option<RunningState>,
}

impl AppStarter {
pub fn with_custom_configuration(configuration: TorrustConfig) -> Self {
Self {
configuration,
running_state: None,
}
}

pub async fn start(&mut self) {
let configuration = Configuration {
settings: RwLock::new(self.configuration.clone()),
};

// Open a channel to communicate back with this function
let (tx, rx) = oneshot::channel::<AppStarted>();

// Launch the app in a separate task
let app_handle = tokio::spawn(async move {
let app = app::run(configuration).await;

// Send the socket address back to the main thread
tx.send(AppStarted {
socket_addr: app.socket_address,
})
.expect("the app should not be dropped");

app.api_server.await
});

// Wait until the app is started
let socket_addr = match rx.await {
Ok(msg) => msg.socket_addr,
Err(e) => panic!("the app was dropped: {e}"),
};

let running_state = RunningState { app_handle, socket_addr };

info!("Test environment started. Listening on {}", running_state.socket_addr);

// Update the app state
self.running_state = Some(running_state);
}

pub fn stop(&mut self) {
match &self.running_state {
Some(running_state) => {
running_state.app_handle.abort();
self.running_state = None;
}
None => {}
}
}

pub fn server_socket_addr(&self) -> Option<SocketAddr> {
self.running_state.as_ref().map(|running_state| running_state.socket_addr)
}
}

#[derive(Debug)]
pub struct AppStarted {
pub socket_addr: SocketAddr,
}

/// Stores the app state when it is running.
pub struct RunningState {
app_handle: tokio::task::JoinHandle<std::result::Result<(), std::io::Error>>,
pub socket_addr: SocketAddr,
}

impl Drop for AppStarter {
/// Child threads spawned with `tokio::spawn()` and tasks spawned with
/// `async { }` blocks will not be automatically killed when the owner of
/// the struct that spawns them goes out of scope.
///
/// The `tokio::spawn()` function and `async { }` blocks create an
/// independent task that runs on a separate thread or the same thread,
/// respectively. The task will continue to run until it completes, even if
/// the owner of the struct that spawned it goes out of scope.
///
/// However, it's important to note that dropping the owner of the struct
/// may cause the task to be orphaned, which means that the task is no
/// longer associated with any parent task or thread. Orphaned tasks can
/// continue running in the background, consuming system resources, and may
/// eventually cause issues if left unchecked.
///
/// To avoid orphaned tasks, we ensure that the app ois stopped when the
/// owner of the struct goes out of scope.
///
/// This avoids having to call `TestEnv::stop()` explicitly at the end of
/// each test.
fn drop(&mut self) {
// Stop the app when the owner of the struct goes out of scope
self.stop();
}
}
23 changes: 21 additions & 2 deletions tests/integration/contexts/about.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
use crate::common::asserts::{assert_response_title, assert_text_ok};
use crate::integration::environment::TestEnv;

#[tokio::test]
#[ignore]
async fn it_should_load_the_about_page_with_information_about_the_api() {
// todo: launch isolated API server for this test
let env = TestEnv::running().await;
let client = env.unauthenticated_client();

let response = client.about().await;

assert_text_ok(&response);
assert_response_title(&response, "About");
}

#[tokio::test]
async fn it_should_load_the_license_page_at_the_api_entrypoint() {
let env = TestEnv::running().await;
let client = env.unauthenticated_client();

let response = client.license().await;

assert_text_ok(&response);
assert_response_title(&response, "Licensing");
}
81 changes: 81 additions & 0 deletions tests/integration/environment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use tempfile::TempDir;
use torrust_index_backend::config::{TorrustConfig, FREE_PORT};

use super::app_starter::AppStarter;
use crate::common::client::Client;
use crate::common::connection_info::{anonymous_connection, authenticated_connection};
use crate::common::random;

pub struct TestEnv {
pub app_starter: AppStarter,
pub temp_dir: TempDir,
}

impl TestEnv {
/// Provides a running app instance for integration tests.
pub async fn running() -> Self {
let mut env = TestEnv::with_test_configuration();
env.start().await;
env
}

/// Provides a test environment with a default configuration for testing
/// application.
pub fn with_test_configuration() -> Self {
let temp_dir = TempDir::new().expect("failed to create a temporary directory");

let configuration = ephemeral(&temp_dir);

let app_starter = AppStarter::with_custom_configuration(configuration);

Self { app_starter, temp_dir }
}

/// Starts the app.
pub async fn start(&mut self) {
self.app_starter.start().await;
}

/// Provides an unauthenticated client for integration tests.
pub fn unauthenticated_client(&self) -> Client {
Client::new(anonymous_connection(
&self
.server_socket_addr()
.expect("app should be started to get the server socket address"),
))
}

/// Provides an authenticated client for integration tests.
pub fn _authenticated_client(&self, token: &str) -> Client {
Client::new(authenticated_connection(
&self
.server_socket_addr()
.expect("app should be started to get the server socket address"),
token,
))
}

/// Provides the API server socket address.
fn server_socket_addr(&self) -> Option<String> {
self.app_starter.server_socket_addr().map(|addr| addr.to_string())
}
}

/// Provides a configuration with ephemeral data for testing.
fn ephemeral(temp_dir: &TempDir) -> TorrustConfig {
let mut configuration = TorrustConfig::default();

// Ephemeral API port
configuration.net.port = FREE_PORT;

// Ephemeral SQLite database
configuration.database.connect_url = format!("sqlite://{}?mode=rwc", random_database_file_path_in(temp_dir));

configuration
}

fn random_database_file_path_in(temp_dir: &TempDir) -> String {
let random_db_id = random::string(16);
let db_file_name = format!("data_{random_db_id}.db");
temp_dir.path().join(db_file_name).to_string_lossy().to_string()
}
2 changes: 2 additions & 0 deletions tests/integration/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
pub mod app_starter;
mod contexts;
pub mod environment;

0 comments on commit 6d5e002

Please sign in to comment.