Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 220
Alex Vergara Avatar asked Mar 12 '26 16:03

Alex Vergara


1 Answers

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.

like image 181
Finomnis Avatar answered Mar 14 '26 22:03

Finomnis



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!