Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 171 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,172 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/go-tstr/tstr.svg)](https://pkg.go.dev/github.com/go-tstr/tstr) [![codecov](https://codecov.io/github/go-tstr/tstr/graph/badge.svg?token=H3u7Ui9PfC)](https://codecov.io/github/go-tstr/tstr)
[![Go Reference](https://pkg.go.dev/badge/github.com/go-tstr/tstr.svg)](https://pkg.go.dev/github.com/go-tstr/tstr) [![codecov](https://codecov.io/github/go-tstr/tstr/graph/badge.svg?token=H3u7Ui9PfC)](https://codecov.io/github/go-tstr/tstr) ![main](https://github.com/go-tstr/tstr/actions/workflows/go.yml/badge.svg?branch=main)

# Better testing with TSTR!
# TSTR: your ultimate testing library!

tstr is testing library allows you to write integration and black-box tests like normal unit tests in Go.

You can declare the test dependencies like:

- compose files
- single containers
- cli commands
- main package of Go program

and let tstr take care of the rest.

## Usage

This library is build on top of two concepts:

- tstr.Tester
- tstr.Dependency

### tstr.Tester

There's two common ways to use tester, either from `func TestMain` or from `func TestXXX`. For both of these approaches there's helper function; `tstr.RunMain` and `tstr.Run`, which make it easy to setup and run `tstr.Tester`.

#### tstr.RunMain

```go
func TestMain(m *testing.M) {
tstr.RunMain(m, tstr.WithDeps(
// Pass test dependencies here.
))
}
```

With `TestMain` approach you will have single test env within the packge.
`tstr.RunMain` will setup the test env you defined, call `m.Run()`, cleanup test env and finally call `os.Exit` with returned exit code.

#### tstr.Run

This approach allows more granular control over test env. For example you can have single test env for each top level test. This can be usefull when you want to avoid any side effects and shared state between tests. Also this approach allows more advaced usage like creating a pool of test envs for parallel testing.

##### tstr.WithFn

Simplest way to use `tstr.Run` is with the `tstr.WithFn` option:

```go
func TestMyFunc(t *testing.T) {
err := tstr.Run(
tstr.WithDeps(
// Pass test dependencies here.
),
tstr.WithFn(func() {
const (
input = 1
expected = 1
)
got := MyFunc(input)
assert.Equal(t, expected, got)
}),
)
require.NoError(t, err)
}
```

##### tstr.WithTable

For table driven tests you can use `tstr.WithTable` which loops over the given test table and executes test function for each element using `t.Run`:

```go
func TestMyFunc(t *testing.T) {
type test struct {
Name string
input int
expected int
}

tests := []test{
{Name: "test-1", input: 1, expected: 1},
{Name: "test-2", input: 2, expected: 2},
{Name: "test-3", input: 3, expected: 3},
}

err := tstr.Run(
tstr.WithDeps(
// Add dependencies here.
),
tstr.WithTable(t, tests, func(t *testing.T, tt test) {
got := MyFunc(tt.input)
assert.Equal(t, tt.expected, got)
}),
)
require.NoError(t, err)
}
```

### tstr.Dependency

`tstr.Dependency` declares an interface for test dependency which can be then controlled by `tstr.Tester`. This repo provides the most commonly used dependecies that user can use within their tests. Since `tstr.Dependency` is just an interface users can also implement their own custom dependencies.

#### Compose

Compose dependecy allows you to define and manage Docker Compose stacks as test dependencies. You can create a Compose stack from projects compose file and control its lifecycle within your tests.

```go
func TestMain(m *testing.M) {
tstr.RunMain(m, tstr.WithDeps(
compose.New(
compose.WithFile("../docker-compose.yaml"),
compose.WithUpOptions(tc.Wait(true)),
compose.WithDownOptions(tc.RemoveVolumes(true)),
compose.WithEnv(map[string]string{"DB_PORT": "5432"}),
compose.WithWaitForService("postgres", wait.ForLog("ready to accept connections")),
),
))
}
```

#### Container

Container dependecy allows you to define and manage single containers as test dependencies. You can use predefined modules from testcontainer-go or create generic container.

```go
func TestMain(m *testing.M) {
tstr.RunMain(m, tstr.WithDeps(
container.New(
container.WithModule(postgres.Run, "postgres:16-alpine",
postgres.WithDatabase("test"),
postgres.WithUsername("user"),
postgres.WithPassword("password"),
),
),
))
}
```

#### Cmd

Cmd dependecy is the most versatile one. It can be used for running any binary or even compiling a Go application and running it as dependency.

This example compiles `my-app` Go application, instruments it for coverage collections, waits for it to be ready and finally starts running tests.

```go
func TestMain(m *testing.M) {
tstr.RunMain(m, tstr.WithDeps(
cmd.New(
cmd.WithGoCode("../", "./cmd/my-app"),
cmd.WithReadyHTTP("http://localhost:8080/ready"),
cmd.WithEnvAppend("GOCOVERDIR=./cover"),
),
))
}
```

#### Custom Dependencies

You can also create your own custom dependencies by implementing the `tstr.Dependency` interface.

```go
type Custom struct{}

func New() *Custom {
return &Custom{}
}

func (c *Custom) Start() error { return nil }

func (c *Custom) Ready() error { return nil }

func (c *Custom) Stop() error { return nil }
```
24 changes: 24 additions & 0 deletions dep/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"bufio"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"regexp"
Expand Down Expand Up @@ -102,6 +103,28 @@
}
}

// WithReadyHTTP sets the ready function to wait for url to return 200 OK.
func WithReadyHTTP(url string) Opt {
return func(c *Cmd) error {
c.ready = func(cmd *exec.Cmd) error {
client := &http.Client{
Timeout: 10 * time.Second,
}
for {
resp, err := client.Get(url)
if err != nil {
continue

Check warning on line 116 in dep/cmd/cmd.go

View check run for this annotation

Codecov / codecov/patch

dep/cmd/cmd.go#L107-L116

Added lines #L107 - L116 were not covered by tests
}
if resp.StatusCode == http.StatusOK {
return nil
}
time.Sleep(100 * time.Millisecond)

Check warning on line 121 in dep/cmd/cmd.go

View check run for this annotation

Codecov / codecov/patch

dep/cmd/cmd.go#L118-L121

Added lines #L118 - L121 were not covered by tests
}
}
return nil

Check warning on line 124 in dep/cmd/cmd.go

View check run for this annotation

Codecov / codecov/patch

dep/cmd/cmd.go#L124

Added line #L124 was not covered by tests
}
}

// WithStopFn allows user to provide custom stop function.
func WithStopFn(fn func(*exec.Cmd) error) Opt {
return func(c *Cmd) error {
Expand Down Expand Up @@ -186,6 +209,7 @@

// WithGoCode builds the given Go projects and sets the main package as the command.
// By default the command is set to collect coverage data.
// Working directory for build command is set to modulePath which means that the mainPkg should be relative to it.
func WithGoCode(modulePath, mainPkg string) Opt {
return func(c *Cmd) error {
dir, err := os.MkdirTemp("", "go-tstr")
Expand Down
3 changes: 1 addition & 2 deletions dep/compose/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ func TestCompose(t *testing.T) {

tests := []struct {
name string
fn func() error
compose *compose.Compose
err error
}{
Expand Down Expand Up @@ -55,7 +54,7 @@ func TestCompose(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deptest.ErrorIs(t, tt.compose, tt.fn, tt.err)
deptest.ErrorIs(t, tt.compose, nil, tt.err)
})
}
}
Expand Down
1 change: 1 addition & 0 deletions dep/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (c *Container) Ready() error {
func (c *Container) Stop() error {
return testcontainers.TerminateContainer(c.c)
}

func WithReadyFn(fn func(testcontainers.Container) error) Opt {
return func(c *Container) error {
c.ready = fn
Expand Down
3 changes: 1 addition & 2 deletions dep/container/container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
func TestContainer(t *testing.T) {
tests := []struct {
name string
fn func() error
container *container.Container
err error
}{
Expand Down Expand Up @@ -76,7 +75,7 @@ func TestContainer(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deptest.ErrorIs(t, tt.container, tt.fn, tt.err)
deptest.ErrorIs(t, tt.container, nil, tt.err)
})
}
}
9 changes: 4 additions & 5 deletions dep/deptest/dep.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import (
)

// ErrorIs is a convinience wrapper around tstr.Run that can be used to test single dependency.
func ErrorIs(t *testing.T, d tstr.Dependency, fn func() error, err error) bool {
func ErrorIs(t *testing.T, d tstr.Dependency, fn func(), err error) bool {
return assert.ErrorIs(t, tstr.Run(
tstr.WithDeps(d),
tstr.WithFn(func() error {
if fn == nil {
return nil
tstr.WithFn(func() {
if fn != nil {
fn()
}
return fn()
}),
), err)
}
10 changes: 1 addition & 9 deletions dep/deptest/dep_test.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
package deptest_test

import (
"errors"
"fmt"
"testing"

"github.com/go-tstr/tstr/dep/deptest"
"github.com/stretchr/testify/assert"
)

func TestErrorIs_NilErr(t *testing.T) {
got := deptest.ErrorIs(t, MockDep{}, func() error { return nil }, nil)
assert.True(t, got)
}

func TestErrorIs_Err(t *testing.T) {
err := errors.New("error")
got := deptest.ErrorIs(t, MockDep{}, func() error { return fmt.Errorf("wrapped: %w", err) }, err)
got := deptest.ErrorIs(t, MockDep{}, func() {}, nil)
assert.True(t, got)
}

Expand Down
7 changes: 5 additions & 2 deletions tester.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,12 @@
}

// WithFn uses the given function as the test function.
func WithFn(fn func() error) Opt {
func WithFn(fn func()) Opt {
return func(t *Tester) error {
return t.setTest(fn)
return t.setTest(func() error {
fn()
return nil
})

Check warning on line 155 in tester.go

View check run for this annotation

Codecov / codecov/patch

tester.go#L153-L155

Added lines #L153 - L155 were not covered by tests
}
}

Expand Down
4 changes: 2 additions & 2 deletions tester_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ func TestRun_Errors(t *testing.T) {
{
name: "overwriting test function",
opts: []tstr.Opt{
tstr.WithFn(func() error { return nil }),
tstr.WithFn(func() error { return nil }),
tstr.WithFn(func() {}),
tstr.WithFn(func() {}),
},
expectedErr: tstr.ErrOverwritingTestFn,
},
Expand Down
Loading