Skip to content

rust testcontainer framework #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Feb 20, 2025
Merged

rust testcontainer framework #41

merged 12 commits into from
Feb 20, 2025

Conversation

kpwebb
Copy link
Contributor

@kpwebb kpwebb commented Feb 10, 2025

Here's my initial shot at a testcontainers implementation for the Rust SDK.

I've got the core functionality working, but wanted to share while in progress to see if adding a new "restate-test-utils" crate to the SDK is ok. Also wanted to solicit feedback on ways to extend the test implementation (e.g. mirroring Temporal's approach), per discussions on Discord.

Here's the current test implementation for a service as defined in tests/test_container.rs

#[tokio::test]
async fn test_container_image() {

    let test_container = TestContainer::new().await.unwrap();

    // uses higher port number to avoid collisions
    // with non-test instances running locally
    let host_port:u16 = 19080;
    let host_address = format!("0.0.0.0:{}", host_port);

    println!("starting host");
    // boot restate server
    tokio::spawn(async move {
        HttpServer::new(
            Endpoint::builder()
                .bind(MyServiceImpl.serve())
                .build(),
        ).listen_and_serve(host_address.parse().unwrap()).await;
    });

    use restate_sdk::service::Discoverable;
    let my_service:Service = ServeMyService::<MyServiceImpl>::discover();

    let registered = test_container.register(host_port).await;
    assert!(registered.is_ok());

    TestContainer::delay(1000).await;

    let response = test_container.invoke(my_service, "my_handler".to_string()).await;

}

@slinkydeveloper
Copy link
Collaborator

This is awesome, i'll give a look in the next days!

Copy link
Collaborator

@slinkydeveloper slinkydeveloper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just left few initial comments, thanks for the contribution!

}


pub struct TestContainer {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if the test infra bootstraps the service endpoint server too, and then on Drop stops the server.

tokio::time::sleep(Duration::from_millis(milliseconds)).await;
}

pub async fn invoke(&self, service:Service, handler:String) -> Result<(), HandlerError> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than this, i would start the user services http server and register them when bootstrapping the test infra, then just provide a url here, and let people use the client they want to send test requests

@kpwebb
Copy link
Contributor Author

kpwebb commented Feb 11, 2025

@slinkydeveloper thanks for the feedback! Just pushed an update that captures the above. Here's the new test implementation:

#[tokio::test]
async fn test_container_image() {

    let mut test_container = TestContainer::new("docker.io/restatedev/restate", "latest").await.unwrap();

    let endpoint = Endpoint::builder()
                    .bind(MyServiceImpl.serve())
                    .build();

    test_container.serve_endpoint(endpoint).await;

    // optionally insert a delays via tokio sleep
    TestContainer::delay(1000).await;

    // optionally call invoke on service handlers
    use restate_sdk::service::Discoverable;
    let my_service:Service = ServeMyService::<MyServiceImpl>::discover();
    let invoke_response = test_container.invoke(my_service, "my_handler").await;

    assert!(invoke_response.is_ok());

    println!("invoke response:");
    println!("{}", invoke_response.unwrap().text().await.unwrap());

}

Copy link
Collaborator

@slinkydeveloper slinkydeveloper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi! Thanks again for the contribution, I had some time to take a look, it's getting there, I think the API needs another pass

@@ -0,0 +1,58 @@
use restate_test_utils::test_utils::TestContainer;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you move this test in restate-test-utils? This way you don't need a dependency on restate-test-utils in the root Cargo.toml. I'm afraid this might cause some issues

.await?
.json::<VersionResponse>()
.await?;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a log line here (with tracing::info) saying restate container started with the forwarded ports

Comment on lines 200 to 203
// todo cleanup on drop?
// testcontainers-rs already implements stop/rm on drop]
// https://docs.rs/testcontainers/latest/testcontainers/
//
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you probably need to stop the hyper server here on drop

Comment on lines 59 to 75
let mut container_stdout = container.stdout(true);
// Spawn a task to copy data from the AsyncBufRead to stdout
let stdout_logging = task::spawn(async move {
let mut stdout = io::stdout();
if let Err(e) = io::copy(&mut container_stdout, &mut stdout).await {
eprintln!("Error copying data: {}", e);
}
});

let mut container_stderr = container.stderr(true);
// Spawn a task to copy data from the AsyncBufRead to stderr
let stderr_logging = task::spawn(async move {
let mut stderr = io::stderr();
if let Err(e) = io::copy(&mut container_stderr, &mut stderr).await {
eprintln!("Error copying data: {}", e);
}
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this should be printed using the tracing::debug macro. People can then configure a test tracing_subscriber using https://docs.rs/tracing-test/latest/tracing_test/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a shot at doing this via tracing::debug and thought it was way too verbose. As an alternative I added a .with_container_logging() option to the builder, but happy to drop back to tracing::debug if preferred.

Comment on lines 35 to 58
async fn test_container_image() {

let mut test_container = TestContainer::new("docker.io/restatedev/restate", "latest").await.unwrap();

let endpoint = Endpoint::builder()
.bind(MyServiceImpl.serve())
.build();

test_container.serve_endpoint(endpoint).await;

// optionally insert a delays via tokio sleep
TestContainer::delay(1000).await;

// optionally call invoke on service handlers
use restate_sdk::service::Discoverable;
let my_service:Service = ServeMyService::<MyServiceImpl>::discover();
let invoke_response = test_container.invoke(my_service, "my_handler").await;

assert!(invoke_response.is_ok());

println!("invoke response:");
println!("{}", invoke_response.unwrap().text().await.unwrap());

}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're getting there with the API, maybe let me describe better what I had in mind that aligns with the other SDKs:

  • For 90% of users, there should be a single API like TestEnvironment::start(Endpoint) that simply takes the endpoint, prepares the environment, registers the services etc. This API returns a TestEnvironment with a method uri() that gives me the base address to send requests to restate. Dropping TestEnvironment stops the Endpoint too. People can then decide to send requests with whatever client they want, be it reqwest, or Implement ingress client #42. For example, something like that:

#[tokio::test]
async fn test_container_image() {
    let endpoint = Endpoint::builder()
                    .bind(MyServiceImpl.serve())
                    .build();

    let mut test_env = TestEnvironment::start(endpoint).await.unwrap();

    let ingress_address: String = test_env.ingress_uri();
    
    // Do whatever requests you want with reqwest, or some http test client crate like https://docs.rs/asserhttp/latest/asserhttp/#reqwest

    // On Drop, TestEnvironment stops both container and Endpoint
}
  • For the remaining 10% of the people, there should be a TestEnvironment::builder().start() that let's you tune few things (such as the image name, which by default should just be `"docker.io/restatedev/restate:latest".

Comment on lines 96 to 97
let host_port:u16 = 19080;
let host_address = format!("0.0.0.0:{}", host_port);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to start the TcpListener on port 0, this way you get a random port assigned. Look at the implementation of HttpServer::listen_and_serve/HttpServer::serve_with_cancel

impl TestContainer {

//"docker.io/restatedev/restate", "latest"
pub async fn new(image:&str, version:&str) -> Result<Self, HandlerError> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use anyhow::Error here and everywhere else in this file, HandlerError is an error type reserved to be used only inside restate service handlers.

Comment on lines 12 to 21
futures = "0.3.31"
http = "1.2.0"
nu-ansi-term = "0.50.1"
reqwest = {version= "0.12.12", features = ["json"]}
restate-sdk = { version = "0.3.2", path = ".." }
restate-sdk-shared-core = "0.2.0"
serde = "1.0.217"
serde_json = "1.0.138"
testcontainers = "0.23.1"
tokio = "1.43.0"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also re-check which dependencies you need here? I think some of those are not needed

edition = "2021"
description = "Test Utilities for Restate SDK for Rust"
license = "MIT"
repository = "https://github.com/restatedev/sdk-rust/test-utils"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just https://github.com/restatedev/sdk-rust is fine

@@ -0,0 +1,21 @@
[package]
name = "restate-test-utils"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rename the package to restate-test-env and the containing directory to test-env

Copy link
Collaborator

@slinkydeveloper slinkydeveloper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome. Run just check locally to fix linting/formatting issues and then we should be good to go!

max_admin_api_version:u32
}

pub struct TestContainerBuilder {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

implement Default too calling new

Comment on lines 131 to 137
while let Err(_) = client.get(format!("{}/discover", self.endpoint_server_url.as_ref().unwrap()))
.header("accept", "application/vnd.restate.endpointmanifest.v1+json")
.send().await {
tokio::time::sleep(Duration::from_secs(1)).await;

warn!("retrying endpoint server");
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put some retry limit here? like after 10 times stop retrying

Comment on lines 139 to 141
client.get(format!("{}/discover", self.endpoint_server_url.as_ref().unwrap()))
.header("accept", "application/vnd.restate.endpointmanifest.v1+json")
.send().await?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this one too?

while let Err(_) = client.get(format!("{}/discover", self.endpoint_server_url.as_ref().unwrap()))
.header("accept", "application/vnd.restate.endpointmanifest.v1+json")
.send().await {
tokio::time::sleep(Duration::from_secs(1)).await;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can lower this down to 100 msecs, and retry for at most 5 seconds.

let ingress_url = test_container.ingress_url();

// call container ingress url for /MyService/my_handler
let resposne = reqwest::Client::new().post(format!("{}/MyService/my_handler", ingress_url))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo

@kpwebb
Copy link
Contributor Author

kpwebb commented Feb 19, 2025

thanks again for the recs and sorry for not running lint/fmt! should be good to go now

Copy link
Collaborator

@slinkydeveloper slinkydeveloper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One minor nit, and I merge

@@ -0,0 +1,21 @@
[package]
name = "test-env"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name is the crate name on crates.io, so it needs to be restate-sdk-test-env (unlike the directory name, which test-env is just fine)

README.md Outdated
### Testing

The SDK uses [Testcontainers](https://rust.testcontainers.org/) to support integration testing using a Docker-deployed restate server.
The `test-env` crate provides a framework for initializing the test environment, and an integration test example in `test-env/tests/test_container.rs`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The `test-env` crate provides a framework for initializing the test environment, and an integration test example in `test-env/tests/test_container.rs`.
The `restate-sdk-test-env` crate provides a framework for initializing the test environment, and an integration test example in `test-env/tests/test_container.rs`.

@slinkydeveloper slinkydeveloper merged commit a656187 into restatedev:main Feb 20, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants