3

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.

Alex Vergara
  • 1,766
  • 1
  • 10
  • 29
  • If you don't want to use a real connection, I think you might have to use a Mock of TcpListener. There are several crates that provide this functionality, or you could implement it yourself. You would then pass in the mock via depencency injection. It's not the simplest thing in the world. – Finomnis Jul 04 '22 at 22:08
  • I was researching for this that you comment, and I can found any reliable for me, neither something that I can implement myself. Can you provide further guidance for this topic? I am also struggling with dependency injection in Rust. Thanks for your time. – Alex Vergara Jul 06 '22 at 06:19
  • What are you trying to test? Your question isn't precise enough ... you need specific test cases for me to help you further. A test consists of the three A's: Arrange, Act, Assert. Tell me a specific test for how you would like to arrange things first, what the action should be, and what the assertion after the action should be. (Like: Arrange: call "run" with a mocked tcp connection, Act: Unsure, maybe send a handshake request? Assert: Maybe the server responds correctly to the handshake request? Currently your server doesn't do anything, so there's nothing to test) – Finomnis Jul 06 '22 at 08:06

1 Answers1

3

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.

Finomnis
  • 18,094
  • 1
  • 20
  • 27