Skip to content

Commit

Permalink
rewrite: re-implement everything to use simpler constructs and syntax…
Browse files Browse the repository at this point in the history
…. Major new concepts are Expectations, Context, and Subtests. Still a WIP, refactoring required.
  • Loading branch information
bearstonedev committed Jun 26, 2024
1 parent 6db95ae commit 1f2192c
Show file tree
Hide file tree
Showing 16 changed files with 495 additions and 598 deletions.
206 changes: 142 additions & 64 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,135 @@ A simple test framework for Go, intended for TDD beginners and novice developers

### :rabbit2: Make it easier to write tests first (TDD) by making tests easier to write.

- An `Assertions` object provides simple test assertions.
- A `Scenarios` interface helps provide a structured way to write parameterized test cases.
This is a test:

```go
package calculator

import (
"github.com/bearstonedev/gotest"
"testing"
)

func Test_ShouldCompareTwoNumbers(t *testing.T) {
calculator := MyCalculator{}
I := gotest.Expec(t)
I.Expect(calculator.Add(1, 1)).ToBe(2)
}
```

Note the readability; the test **documents** the expected result. Here's how the same test would be written in standard
Go:

```go
package calculator

import "testing"

func Test_ShouldCompareTwoNumbers(t *testing.T) {
calculator := MyCalculator{}
t.Parallel()
t.Run("", func(tt *testing.T) {
tt.Parallel()
expected := 2
actual := calculator.Add(1, 1)
if actual != expected {
tt.Fatalf("Expected %v to be %v", expected, actual)
}
})
}
```

You can write multiple _expectations_ in one test ...

```go
package calculator

import (
"github.com/bearstonedev/gotest"
"testing"
)

func Test_ShouldCompareTwoNumbers(t *testing.T) {
calc := CreateCalculator()
I := gotest.Expec(t)
I.Expect(calc.IsGreaterThan(2, 1)).ToBe(true)
I.Expect(calc.IsGreaterThan(1, 2)).ToBe(false)
}
```

...and you can name them, if you want to:

```go
package calculator

import (
"github.com/bearstonedev/gotest"
"testing"
)

func Test_ShouldCompareTwoNumbers(t *testing.T) {
calc := CreateCalculator()
I := gotest.Expec(t)
I.Expect(calc.IsGreaterThan(2, 1)).As("2 > 1").ToBe(true) // Test will be named: I_expect_2_>_1_to_be_true
I.Expect(calc.IsGreaterThan(1, 2)).As("1 > 2").ToBe(false) // Test will be named: I_expect_1_>_2_to_be_false
}
```

Each expectation is run as a [subtest](https://go.dev/blog/subtests) of the containing test.

### :memo: Make it easier to write descriptive specifications of behaviour (BDD) by reducing boilerplate test code.

- All tests are run in parallel, both to reduce boilerplate and to help enforce independent tests.
- `t.FailNow`, `t.Log`, etc. are abstracted.

### :children_crossing: Maintain maximal compatibility with the existing Go toolset.
### :dancers: Make it easier to write parallel tests.

Each "expectation" is run as a distinct, parallel test. This helps ensure:

- Tests are **temporally decoupled**, meaning they don't need to be run in a specific order;
- Tests are **distinct**, making it easier to localize a specific failure; and,
- Tests are **safe**
because [low-level test implementation details](https://gist.github.com/posener/92a55c4cd441fc5e5e85f27bca008721) are
managed on your behalf.

### :children_crossing: Maintain compatibility with the existing Go toolset.

- Standard `go` commands should work as expected to automate testing.
- Example: `go test -run 'SomeTestSuite/specific_scenario' -v` should produce the same result as with native Go tests.

## Usage
## Detailed Usage for Beginners

### What are tests?

Test are written to **specify expected behaviour**; we use them to measure and evaluate the software we write. Tests
should be written *in advance* to help guide design and implementation; this is known as **[test-driven development
(TDD)](https://martinfowler.com/bliki/TestDrivenDevelopment.html)**.

### 1. Write a "parent" test for a feature or behaviour.
A test is a function which invokes a part of the software we're writing; this can be a function or method, a module, or
even an entire application. The test specifies the expected inputs (values we provide to our software) and outputs (
values we receive from our software) for a specific behaviour. A collection of tests, known as a **test suite**, helps
us identify problems with our design and/or implementation *during development* when those problems are easiest to
address.

This will contain one or more _related_ tests focused on a behaviour or outcome, and it should adhere
to [standard Go test conventions](https://pkg.go.dev/testing):
### 1. Write a test function for a feature or behaviour.

```golang
This function should follow [standard Go test conventions](https://pkg.go.dev/testing).

```go
package pizza_ordering

import (
"testing"
)
import "testing"

func TestOrderingADeluxePizza(t *testing.T) {}

```

### 2. Use `Tests(t)` to wrap the `*testing.T` pointer.
### 2. Use `Expec(t)` to wrap the `*testing.T` pointer.

This is the "starting point" for writing tests, and will be used to write one or more test cases; these tests should be
__cohesive__ (related to each other).

```golang
```go
package pizza_ordering

import (
Expand All @@ -51,18 +143,14 @@ import (
)

func TestOrderingADeluxePizza(t *testing.T) {
Tests(t)
I := Expec(t)
}

```

### 3. Write the tests.
### 3. Set up the system-under-test (SUT).

There are a few options for implementing test cases, depending on what you're testing:

#### a. `Test()` will create a standalone parallelized test.

`Test()` can be chained to create multiple tests, like this:
Depending on what you're testing, you might have a type or module to be tested; this is colloquially known as the *
*system-under-test** (SUT). You should set it up with any relevant preconditions for the scenario you're testing.

```go
package pizza_ordering
Expand All @@ -73,24 +161,15 @@ import (
)

func TestOrderingADeluxePizza(t *testing.T) {
order := "deluxe pizza"
Tests(t).
Test("the pizza should be fully-cooked", func(shouldBe Assertions) {
pizza := systemUnderTest.orderPizza(order)
shouldBe.True(pizza.IsCooked())
}).
Test("the pizza should have deluxe toppings", func(shouldBe Assertions) {
pizza := systemUnderTest.orderPizza(order)
shouldBe.Equal(pizza.Toppings(), []string{"green pepper", "mushroom", "tomato"})
})
}
I := Expec(t)

sut := &PizzaOrderMaker{location: "New York"}
}
```

#### b. `Scenarios()` sets up a parameterized test.
### 4. Write one or more expectations (tests).

One or more `Scenario()`s should be specified, followed by a `Test()` call to define the test case for each scenario,
like this:
This will compare the expected output to the output produced by your program.

```go
package pizza_ordering
Expand All @@ -100,42 +179,41 @@ import (
"testing"
)

func TestOrderingAPizzaWithCustomToppings(t *testing.T) {
Tests(t).
Scenarios().
Scenario("one topping", []string{"green pepper"}).
Scenario("two toppings", []string{"green pepper", "tomatoes"}).
Test(func(shouldBe Assertions, parameters []any) {
order := parameters[0].([]string)
pizza := systemUnderTest.orderPizza(order)
shouldBe.Equal(pizza.Toppings(), order)
})
}
func TestOrderingADeluxePizza(t *testing.T) {
I := Expec(t)

sut := &PizzaOrderMaker{location: "New York"}
expected := &PizzaOrder{
toppings: []string{"tomato", "green olives", "red peppers"},
locale: "New York",
}
actual := sut.OrderPizza("tomato", "green olives", "red peppers")

I.Expect(actual.toppings).ToBe(expected.toppings)
I.Expect(actual.locale).ToBe(expected.locale)
}
```

#### The `Assertions` object provides methods for asserting post-conditions.
### 5. Write the implementation to make the test pass.

`Assertions` is injected with test-specific information via the test case:
Now that you have a failing test, you should implement the changes needed to make that test pass. Try to avoid writing
many tests upfront, or writing tests after-the-fact; writing tests and implementation iteratively will "drive" the
design of the software you're building in small increments.

```go
package pizza_ordering
### 6. Refactor.

import (
. "github.com/bearstonedev/gotest"
"testing"
)
> Refactoring (noun): a change made to the internal structure of software to make it easier to understand and cheaper to
> modify without changing its observable behavior.
> Refactoring (verb): to restructure software by applying a series of refactorings without changing its observable
> behavior.
https://martinfowler.com/bliki/DefinitionOfRefactoring.html

func TestOrderingAPizzaWithCustomCrust(t *testing.T) {
order := "thin crust"
Tests(t).
Test("the pizza should have the expected crust", func(shouldBe Assertions) {
pizza := systemUnderTest.orderPizza(order)
shouldBe.Equal(pizza.Crust(), "thin")
})
}
Refactoring must be done frequently to minimize accidental complexity. When building software, try working in cycles
of "Red-Green-Refactor":

```
1. Write a failing test;
2. Make that test pass;
3. Refactor the test and the implementation.

## Examples

Expand Down
54 changes: 0 additions & 54 deletions assertions.go

This file was deleted.

Loading

0 comments on commit 1f2192c

Please sign in to comment.