Strive for Platform-Independent Tests
Write platform-independent tests whenever possible.
This suggestion is corollary to and supports the previous two guidelines. Maintenance of multi-platform code is not a trivial business, and has its cost. By making sure that the code is designed to be as much as possible independent of the platform it's run on, and only really low-level parts do differ, we have hopefully reduced the cost. But the code has to be tested, and since tests are also code, the same rules apply.
Platform-independent approach to tests works best in a case where the result of an operation or behaviour must be the same across all platforms despite different low-level implementation details. In such a case, the low-level code does not necessarily need to be covered, as long as tests are run against all target platforms.
To illustrate this, let's slightly update our example. We'll add a feature that has to work uniformly across all platforms, but will have three different implementations. Let's say we need to add a fourth feature which prints "this feature has different implementations but yields the same result" on all three supported operating systems (here, as in GOOS).
This is what the code looks like:
- this is our platform-independent high-level code,
something.go:
package something
import (
"fmt"
)
const (
Feature1 = "common feature for all platforms"
)
func RunFeature4() {
fmt.Println(Feature4())
}
func Feature4() string {
return doFeature4()
}
Notice the RunFeature4 function which internally calls the doFeature4 function. Where is it declared? It's not declared in any file that is visible for all platforms simultaneously. Instead, each of the platforms has its own implementation. Here we go:
- for
darwin,something_darwin.go:
package something
import (
"fmt"
)
var (
feature4 = []byte(`this feature has different implementations but yields the same result`)
)
func DoFeature3() {
fmt.Println("this is a unique feature for the mac")
}
func doFeature4() string {
return string(feature4)
}
Here, the special implementation of doFeature4 uses the slice of bytes as the source.
- for
linux,something_linux.go:
package something
func DoFeature3() {}
func doFeature4() string {
return "this feature has different implementations but yields the same result"
}
In the world of Linux, things are often more straightforward. We just return a string.
- here is
windows,something_windows.go:
package something
const (
Feature2 = "this is a windows specific version"
)
var (
feature4 = []rune(`this feature has different implementations but yields the same result`)
)
func DoFeature3() {}
func doFeature4() string {
return string(feature4)
}
In Windows, our implementation is based on a slice of runes.
- finally, this is how the feature is used,
main.go:
package main
import (
"fmt"
"../../something"
)
func main() {
fmt.Println(something.Feature1)
fmt.Println(something.Feature2)
something.DoFeature3()
something.RunFeature4()
}
- building and running:
# Darwin.
$ GOOS=darwin go build -o bin/myapp-darwin ./cmd/myapp
$ ./bin/myapp-darwin
common feature for all platforms
this is a version supported by a number of unix-like systems
this is a unique feature for the mac
this feature has different implementations but yields the same result
# Linux.
$ GOOS=linux go build -o bin/myapp-linux ./cmd/myapp
$ docker run -it --rm -v $(pwd)/bin:/root/bin/ alpine:3.13 ash
% /root/bin/myapp-linux
common feature for all platforms
this is a version supported by a number of unix-like systems
this feature has different implementations but yields the same result
The output suggests that the feature works as expected. But how can we prove it remains working over time, especially given that changes to software are made often, not to mention that platforms do not stand still too, and are being constantly updated? Of course, with tests. In this case, the outcome is and must be exactly the same for all platforms. So this outcome is the result we want to make sure is always correct, not the implementation detail. If something happens to the implementation, then the broken test will highlight it.
In cases like this, write platform-independent tests.
To illustrate this situation, here are two example tests:
- the first checks the feature itself
- the second tests how it works end-to-end.
Here is the something_test.go file, which is in effect on all platforms:
package something_test
import (
"bufio"
"os"
"strings"
"testing"
"../something"
)
func TestFeature4(t *testing.T) {
exp := "this feature has different implementations but yields the same result"
act := something.Feature4()
if act != exp {
t.Errorf("expected: %s, got: %s\n", exp, act)
}
}
func TestRunFeature4(t *testing.T) {
stdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Errorf("failed to create reader and writer: %s\n", err)
t.FailNow()
}
os.Stdout = w
something.RunFeature4()
b := bufio.NewReader(r)
out, err := b.ReadString('\n')
if err != nil {
t.Errorf("failed to read from reader: %s\n", err)
t.FailNow()
}
if err := w.Close(); err != nil {
t.Errorf("failed to close writer: %s\n", err)
t.FailNow()
}
os.Stdout = stdout
exp := "this feature has different implementations but yields the same result"
if act := strings.TrimSuffix(out, "\n"); act != exp {
t.Errorf("expected: %q, got: %q\n", exp, act)
}
}
Notice how neither of the two tests has any knowledge about the platform-specific code. In the first test, TestFeature4, we check that the feature is implemented correctly. The second test, TestRunFeature4, does a full check, including capturing the result from the standard output.
Here are test results:
- on macOS:
$ go test ./something/... -v
=== RUN TestFeature4
--- PASS: TestFeature4 (0.00s)
=== RUN TestRunFeature4
--- PASS: TestRunFeature4 (0.00s)
PASS
ok _/Users/user/projects/xplatform/something 0.005s
- and on Linux, using the
golang:1.15image:
$ docker run -it --rm -v $(pwd):/root/xplatform golang:1.15 bash
% cd /root/xplatform/
% go test ./something/... -v
=== RUN TestFeature4
--- PASS: TestFeature4 (0.00s)
=== RUN TestRunFeature4
--- PASS: TestRunFeature4 (0.00s)
PASS
ok _/root/xplatform/something 0.003s
The output is exactly the same, and tests pass in both cases. And that's what we should strive for.