Skip to content

Commit 4fd732d

Browse files
committed
feat: add unit test feature
Signed-off-by: Andrew Steurer <94206073+asteurer@users.noreply.github.com>
1 parent f17393e commit 4fd732d

7 files changed

Lines changed: 268 additions & 22 deletions

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,3 @@ examples/*/go.mod
99
examples/*/go.sum
1010
examples/*/wasi_*
1111
examples/*/wit_*
12-
examples/*/export_wasi*

examples/unit_tests/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Unit Tests
2+
3+
There are two scenarios that componentize-go supports for building unit tests:
4+
- [Basic unit tests](#basic-unit-tests) (Can be run using the wasmtime cli tool)
5+
- [Guest-Host unit tests](#guest-host-unit-tests) (requires a custom runtime implementation)
6+
7+
We recognize that building unit tests with Go and WebAssembly is a bit cumbersome at the moment, so feel free submit a PR or issue if you have feedback and/or improvements.
8+
9+
## Basic unit tests
10+
11+
These are unit tests that don't require any WIT files and are the easiest to work with. The [wasip2 example](../wasip2/) gives a demonstration for how to do this.
12+
13+
## Guest-Host unit tests
14+
15+
If tests require guest-host interactions via WIT, the required `//go:wasmexport` and `//go:wasmimport` functions needs to be present in the testfile. To get the [wasip2 example](../wasip2/) to compile, you'll need to append this codeblock to the end of the [handler_test.go file](../wasip2/export_wasi_http_incoming_handler/handler_test.go):
16+
17+
```go
18+
//go:wasmexport wasi:http/incoming-handler@0.2.0#handle
19+
func wasm_export_wasi_http_incoming_handler_handle(arg0 int32, arg1 int32) {
20+
Handle(wasi_http_types.IncomingRequestFromOwnHandle(int32(uintptr(arg0))), wasi_http_types.ResponseOutparamFromOwnHandle(int32(uintptr(arg1))))
21+
}
22+
```
23+
24+
This codeblock is auto-generated in the [wit_exports.go file](../wasip2/wit_exports.go).
25+
26+
Once the above codeblock is appended, run the following command to build the test component:
27+
28+
```sh
29+
cd ../wasip2
30+
componentize-go --world wasip2-example componentize-tests --pkg ./export_wasi_http_incoming_handler
31+
```
32+
33+
We haven't figured out the best way to run these, so stay tuned or feel free to try creating your own host implementation with wasmtime and see if you can find a way to get these to work!
34+
35+
## Mixing Basic and Guest-Host unit tests
36+
37+
It is not currently recommended to mix the two types of unit tests in the same package. componentize-go currently has no way of differentiating between the two.

examples/wasip2/Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@ build-component: generate-bindings
88
.PHONY: run
99
run: build-component
1010
wasmtime serve -Sp3,cli main.wasm
11+
12+
# See the README in ../unit_tests for more information about using the `componentize-tests` subcommand
13+
build-tests: generate-bindings
14+
componentize-go componentize-tests --wasip1 --pkg ./export_wasi_http_incoming_handler
15+
16+
run-tests: build-tests
17+
wasmtime run test_export_wasi_http_incoming_handler.wasm

examples/wasip2/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,3 @@ make run
1313
# Invoke the application
1414
curl localhost:8080
1515
```
16-
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package export_wasi_http_incoming_handler
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func test_sum(a, b int) int {
8+
return a + b
9+
}
10+
11+
func TestSum(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
a, b int
15+
expected int
16+
}{
17+
{"positive numbers", 2, 3, 5},
18+
{"negative numbers", -1, -2, -3},
19+
{"zeros", 0, 0, 0},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
got := test_sum(tt.a, tt.b)
25+
if got != tt.expected {
26+
t.Errorf("test_sum(%d, %d) = %d, expected %d", tt.a, tt.b, got, tt.expected)
27+
}
28+
})
29+
}
30+
}

src/command.rs

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{bindings::generate_bindings, componentize};
2-
use anyhow::Result;
2+
use anyhow::{Result, anyhow};
33
use clap::{Parser, Subcommand};
44
use std::{ffi::OsString, path::PathBuf};
55

@@ -49,6 +49,9 @@ pub enum Command {
4949
/// Build a Go WebAssembly component.
5050
Componentize(Componentize),
5151

52+
/// Build Go test WebAssembly components.
53+
ComponentizeTests(ComponentizeTests),
54+
5255
/// Generate Go bindings for the world.
5356
Bindings(Bindings),
5457
}
@@ -68,6 +71,37 @@ pub struct Componentize {
6871
pub mod_path: Option<PathBuf>,
6972
}
7073

74+
#[derive(Parser)]
75+
pub struct ComponentizeTests {
76+
/// The path to the Go binary (or look for binary in PATH if `None`).
77+
#[arg(long)]
78+
pub go: Option<PathBuf>,
79+
80+
/// A package containing Go test files.
81+
///
82+
/// This may be specified more than once, for example:
83+
/// `--pkg ./cmd/foo --pkg ./cmd/bar`.
84+
///
85+
/// The test components will be named using the last segment of the provided path, for example:
86+
/// `--pkg ./foo/bar/baz` will result in a file named `test_bar_baz.wasm`
87+
#[arg(long)]
88+
pub pkg: Vec<PathBuf>,
89+
90+
/// Output directory for test components (or current directory if `None`).
91+
///
92+
/// This will be created if it does not already exist.
93+
#[arg(long, short = 'o')]
94+
pub output: Option<PathBuf>,
95+
96+
/// Compiles the tests to a wasip1 component.
97+
///
98+
/// This is helpful for unit tests that aren't interacting with the host.
99+
/// Note: componentize-go has no way to differentiate between wasip1 and wasip2+ tests,
100+
/// so it is recommended to keep the tests in separate packages.
101+
#[arg(long)]
102+
pub wasip1: bool,
103+
}
104+
71105
#[derive(Parser)]
72106
pub struct Bindings {
73107
/// Output directory for bindings (or current directory if `None`).
@@ -95,28 +129,61 @@ pub fn run<T: Into<OsString> + Clone, I: IntoIterator<Item = T>>(args: I) -> Res
95129
match options.command {
96130
Command::Componentize(opts) => componentize(options.common, opts),
97131
Command::Bindings(opts) => bindings(options.common, opts),
132+
Command::ComponentizeTests(opts) => componentize_tests(options.common, opts),
98133
}
99134
}
100135

101136
fn componentize(common: Common, componentize: Componentize) -> Result<()> {
102-
// Step 1: Build a WebAssembly core module using Go.
103-
let core_module = componentize::build_wasm_core_module(
104-
componentize.mod_path,
105-
componentize.output,
106-
componentize.go,
137+
// Step 1: Build a wasip1 component using `go build`.
138+
let p1_component = componentize::build_wasm_p1_component(
139+
componentize.mod_path.as_ref(),
140+
componentize.output.as_ref(),
141+
componentize.go.as_ref(),
107142
)?;
108143

109-
// Step 2: Embed the WIT documents in the core module.
144+
// Step 2: Embed the WIT documents in the wasip1 component.
110145
componentize::embed_wit(
111-
&core_module,
146+
&p1_component,
112147
&common.wit_path,
113148
common.world.as_deref(),
114149
&common.features,
115150
common.all_features,
116151
)?;
117152

118-
// Step 3: Update the core module to use the component model ABI.
119-
componentize::core_module_to_component(&core_module)?;
153+
// Step 3: Update the wasip1 component to use the current component model ABI.
154+
componentize::p1_to_current(&p1_component)?;
155+
Ok(())
156+
}
157+
158+
fn componentize_tests(common: Common, componentize_tests: ComponentizeTests) -> Result<()> {
159+
if componentize_tests.pkg.is_empty() {
160+
return Err(anyhow!("Path to a package containing Go tests is required"));
161+
}
162+
163+
for pkg in componentize_tests.pkg.iter() {
164+
// Step 1: Build a wasip1 component using `go test -c`.
165+
let p1_component = componentize::build_test_wasm_p1_component(
166+
pkg,
167+
componentize_tests.output.as_ref(),
168+
componentize_tests.go.as_ref(),
169+
componentize_tests.wasip1,
170+
)?;
171+
172+
if !componentize_tests.wasip1 {
173+
// Step 2: Embed the WIT documents in the wasip1 component.
174+
componentize::embed_wit(
175+
&p1_component,
176+
&common.wit_path,
177+
common.world.as_deref(),
178+
&common.features,
179+
common.all_features,
180+
)?;
181+
182+
// Step 3: Update the wasip1 component to use the current component model ABI.
183+
componentize::p1_to_current(&p1_component)?;
184+
}
185+
}
186+
120187
Ok(())
121188
}
122189

src/componentize.rs

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use crate::common::{make_path_absolute, parse_wit};
22
use anyhow::{Context, Result, anyhow};
3-
use std::{path::PathBuf, process::Command};
3+
use std::{
4+
path::{Path, PathBuf},
5+
process::Command,
6+
};
47

58
/// Ensure that the Go version is compatible with the embedded Wasm tooling.
69
fn check_go_version(go_path: &PathBuf) -> Result<()> {
@@ -43,8 +46,8 @@ fn check_go_version(go_path: &PathBuf) -> Result<()> {
4346
}
4447
}
4548

46-
/// Update the WebAssembly core module to use the component model ABI.
47-
pub fn core_module_to_component(wasm_file: &PathBuf) -> Result<()> {
49+
/// Update the wasip1 component to use the current component model ABI.
50+
pub fn p1_to_current(wasm_file: &PathBuf) -> Result<()> {
4851
// In the rare case the snapshot needs to be updated, the latest version
4952
// can be found here: https://github.com/bytecodealliance/wasmtime/releases
5053
const WASIP1_SNAPSHOT: &[u8] = include_bytes!("wasi_snapshot_preview1.reactor.wasm");
@@ -84,11 +87,116 @@ pub fn embed_wit(
8487
Ok(())
8588
}
8689

87-
/// Compiles a Go application to WebAssembly core.
88-
pub fn build_wasm_core_module(
89-
go_module: Option<PathBuf>,
90-
out: Option<PathBuf>,
91-
go_path: Option<PathBuf>,
90+
pub fn build_test_wasm_p1_component(
91+
path: &Path,
92+
output_dir: Option<&PathBuf>,
93+
go_path: Option<&PathBuf>,
94+
use_wasip1_flags: bool,
95+
) -> Result<PathBuf> {
96+
let go = match &go_path {
97+
Some(p) => make_path_absolute(p)?,
98+
None => PathBuf::from("go"),
99+
};
100+
101+
check_go_version(&go)?;
102+
103+
let test_wasm_path = {
104+
// The directory in which the test component will be placed
105+
let test_dir = match output_dir {
106+
Some(p) => make_path_absolute(p)?,
107+
None => std::env::current_dir()?,
108+
};
109+
110+
test_dir.join(get_test_filename(path))
111+
};
112+
113+
// Ensuring the newly compiled wasm file overwrites any previously-existing wasm file
114+
if test_wasm_path.exists() {
115+
std::fs::remove_file(&test_wasm_path)?;
116+
}
117+
118+
if let Some(dir) = output_dir {
119+
std::fs::create_dir_all(dir)?;
120+
}
121+
122+
// The -ldflags arg mutes the unit test output, so it is ommitted
123+
let wasip1_args = [
124+
"test",
125+
"-c",
126+
"-buildmode=c-shared",
127+
"-o",
128+
test_wasm_path
129+
.to_str()
130+
.expect("the combined paths of 'output-dir' and 'pkg' are not valid unicode"),
131+
path.to_str().expect("pkg path is not valid unicode"),
132+
];
133+
134+
let wasip2_and_greater_args = [
135+
"test",
136+
"-c",
137+
"-buildmode=c-shared",
138+
"-ldflags=-checklinkname=0",
139+
"-o",
140+
test_wasm_path
141+
.to_str()
142+
.expect("the combined paths of 'output-dir' and 'pkg' are not valid unicode"),
143+
path.to_str().expect("pkg path is not valid unicode"),
144+
];
145+
146+
let output = if use_wasip1_flags {
147+
Command::new(&go)
148+
.args(wasip1_args)
149+
.env("GOOS", "wasip1")
150+
.env("GOARCH", "wasm")
151+
.output()?
152+
} else {
153+
Command::new(&go)
154+
.args(wasip2_and_greater_args)
155+
.env("GOOS", "wasip1")
156+
.env("GOARCH", "wasm")
157+
.output()?
158+
};
159+
160+
if !output.status.success() {
161+
return Err(anyhow!(
162+
"'go test -c' command failed: {}",
163+
String::from_utf8_lossy(&output.stderr)
164+
));
165+
}
166+
167+
Ok(test_wasm_path)
168+
}
169+
170+
/**
171+
* `./foo/bar/baz` should be `test_bar_baz.wasm`
172+
* `./foo/bar` should be `test_foo_bar.wasm`
173+
* `./bar` should be `test_bar.wasm`
174+
* `/usr/bin/foo/bar/baz` should be `test_bar_baz.wasm`
175+
*/
176+
fn get_test_filename(path: &Path) -> String {
177+
let components: Vec<&str> = path
178+
.components()
179+
.filter_map(|c| match c {
180+
// Filter out the `/` and `.`
181+
std::path::Component::Normal(s) => s.to_str(),
182+
_ => None,
183+
})
184+
.collect();
185+
186+
let tail = if components.len() >= 2 {
187+
&components[components.len() - 2..]
188+
} else {
189+
&components[..]
190+
};
191+
192+
format!("test_{}.wasm", tail.join("_"))
193+
}
194+
195+
/// Compiles a Go application to a wasip1 component.
196+
pub fn build_wasm_p1_component(
197+
go_module: Option<&PathBuf>,
198+
out: Option<&PathBuf>,
199+
go_path: Option<&PathBuf>,
92200
) -> Result<PathBuf> {
93201
let go = match &go_path {
94202
Some(p) => make_path_absolute(p)?,
@@ -102,8 +210,7 @@ pub fn build_wasm_core_module(
102210
None => std::env::current_dir()?.join("main.wasm"),
103211
};
104212

105-
// The `go build` command doesn't overwrite the output file, which causes
106-
// issues if the `componentize-go componentize` command is run multiple times.
213+
// Ensuring the newly compiled wasm file overwrites any previously-existing wasm file
107214
if out_path_buf.exists() {
108215
std::fs::remove_file(&out_path_buf)?;
109216
}

0 commit comments

Comments
 (0)