Search code examples
rusttcpintegration-testing

How to write an integration test for a http server event loop?


Taking the base example for the final project on The Book:

use std::net::TcpListener;

mod server {
    fn run() {
        let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

        for stream in listener.incoming() {
            let stream = stream.unwrap();

            println!("Connection established!");
        }
    }
}

I am trying to write an integration test for that piece of code. Obviously, my test it's running to the infinite and beyond because the event loop of the TCP stream provided by the std.

// tests/server.rs

#[test]
fn run() {
    server::run();

    // the rest of the code's test...
}

What would be a good way to test that the server stands up correctly (without need to receive any request) without change the public interface for just testing purposes?

NOTE: There's no assertions neither Result<T, E> of any type because I didn't even know how to set up the test case for something that it's running endless.


Solution

  • I wouldn't test the entire server from within Rust, I'd instead test components of it. The highest component I would test is a single connection.

    Dependency injection in Rust usually works like this:

    • Use traits for parameters instead of specific object types
    • Create a mock of the object that also implements the desired trait
    • Use the mock to create the desired behaviour during tests

    In our case, I will use io::Read + io::Write to abstract TcpStream, as that is all the funcionality we use. If you need further functionality instead of just those two, you might have to implement your own NetworkStream: Send + Sync trait or similar, in which you can proxy further functionality of TcpStream.

    The Mock I will be using is SyncMockStream from the mockstream crate.

    For the following examples you need to add mockstream to your Cargo.toml:

    [dev-dependencies]
    mockstream = "0.0.3"
    

    First, here is the simpler version with just io::Read + io::Write:

    mod server {
        use std::{io, net::TcpListener};
    
        fn handle_connection(
            mut stream: impl io::Read + io::Write,
        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
            println!("Connection established!");
    
            // Read 'hello'
            let mut buf = [0u8; 5];
            stream.read_exact(&mut buf)?;
            if &buf != b"hello" {
                return Err(format!("Received incorrect data: '{:?}'", buf).into());
            }
    
            println!("Received 'hello'. Sending 'world!' ...");
    
            // Respond with 'world!'
            stream.write_all(b"world!\n")?;
            stream.flush()?;
    
            println!("Communication finished. Closing connection ...");
    
            Ok(())
        }
    
        pub fn run(addr: &str) {
            let listener = TcpListener::bind(addr).unwrap();
    
            for stream in listener.incoming() {
                let stream = stream.unwrap();
    
                std::thread::spawn(move || {
                    if let Err(e) = handle_connection(stream) {
                        println!("Connection closed with error: {}", e);
                    } else {
                        println!("Connection closed.");
                    }
                });
            }
        }
    
        #[cfg(test)]
        mod tests {
            use super::*;
    
            use mockstream::SyncMockStream;
            use std::time::Duration;
    
            #[test]
            fn hello_world_handshake() {
                // Arrange
                let mut stream = SyncMockStream::new();
                let connection = stream.clone();
                let connection_thread = std::thread::spawn(move || handle_connection(connection));
    
                // Act
                stream.push_bytes_to_read(b"hello");
                std::thread::sleep(Duration::from_millis(100));
    
                // Assert
                assert_eq!(stream.pop_bytes_written(), b"world!\n");
                connection_thread.join().unwrap().unwrap();
            }
        }
    }
    
    fn main() {
        server::run("127.0.0.1:7878");
    }
    
    > nc localhost 7878
    hello
    world!
    
    > cargo test
    running 1 test
    test server::tests::hello_world_handshake ... ok
    
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
    

    Now if we need further functionality, like the sender address, we can introduce our own trait NetworkStream:

    mod traits {
        use std::io;
    
        pub trait NetworkStream: io::Read + io::Write {
            fn peer_addr_str(&self) -> io::Result<String>;
        }
    
        impl NetworkStream for std::net::TcpStream {
            fn peer_addr_str(&self) -> io::Result<String> {
                self.peer_addr().map(|addr| addr.to_string())
            }
        }
    }
    
    mod server {
        use crate::traits::NetworkStream;
        use std::net::TcpListener;
    
        fn handle_connection(
            mut stream: impl NetworkStream,
        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
            println!("Connection established!");
    
            // Read 'hello'
            let mut buf = [0u8; 5];
            stream.read_exact(&mut buf)?;
            if &buf != b"hello" {
                return Err(format!("Received incorrect data: '{:?}'", buf).into());
            }
    
            println!("Received 'hello'. Sending response ...");
    
            // Respond with 'world!'
            stream.write_all(format!("hello, {}!\n", stream.peer_addr_str()?).as_bytes())?;
            stream.flush()?;
    
            println!("Communication finished. Closing connection ...");
    
            Ok(())
        }
    
        pub fn run(addr: &str) {
            let listener = TcpListener::bind(addr).unwrap();
    
            for stream in listener.incoming() {
                let stream = stream.unwrap();
    
                std::thread::spawn(move || {
                    if let Err(e) = handle_connection(stream) {
                        println!("Connection closed with error: {}", e);
                    } else {
                        println!("Connection closed.");
                    }
                });
            }
        }
    
        #[cfg(test)]
        mod tests {
            use super::*;
    
            use mockstream::SyncMockStream;
            use std::time::Duration;
    
            impl crate::traits::NetworkStream for SyncMockStream {
                fn peer_addr_str(&self) -> std::io::Result<String> {
                    Ok("mock".to_string())
                }
            }
    
            #[test]
            fn hello_world_handshake() {
                // Arrange
                let mut stream = SyncMockStream::new();
                let connection = stream.clone();
                let connection_thread = std::thread::spawn(move || handle_connection(connection));
    
                // Act
                stream.push_bytes_to_read(b"hello");
                std::thread::sleep(Duration::from_millis(100));
    
                // Assert
                assert_eq!(stream.pop_bytes_written(), b"hello, mock!\n");
                connection_thread.join().unwrap().unwrap();
            }
        }
    }
    
    fn main() {
        server::run("127.0.0.1:7878");
    }
    
    > nc localhost 7878
    hello
    hello, 127.0.0.1:50718!
    
    > cargo test
    running 1 test
    test server::tests::hello_world_handshake ... ok
    
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
    

    Note that in both cases, the mockstream dependency is only needed as a dev-dependency. The actual cargo build does not require it.


    Integration testing

    If you want to go further up and test the entire server, I would treat the server as a black box instead and test it with an external tool like behave.

    Behave is a behaviour test framework based on Python and Gherkin which is great for black box integration tests.

    With it, you can run the actual, unmocked executable that cargo build produces, and then test actual functionality with a real connection. behave is excellent with that, especially in the regard that it bridges the gap between programmers and requirement engineers, as the actual test cases are in written, non-programmer-readable form.