diff --git a/README.md b/README.md index d94d590..2c4f247 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- logger + writing tests is relaxing
build-status go report @@ -15,7 +15,7 @@ # blugnu/test -Provides a concise, fluent, type-safe API over the standard testing framework, simplifying common tests +A concise, fluent, type-safe API over the standard testing framework, simplifying common tests in an extensible fashion whilst maintaining compatibility with the standard testing package. _Friends don't let friends write tests that are hard to read, hard to maintain, or that @@ -41,7 +41,7 @@ func TestDoSomething(t *testing.T) { } ``` -Do this instead: +Do this: ```go func TestDoSomething(t *testing.T) { @@ -129,13 +129,14 @@ value from the test function as an argument: } ``` -> :bulb: Calling `Parallel(t)` is equivalent to calling `With(t)` followed by `Parallel()` or `t.Parallel()`. +> :bulb: to execute a test parallel, use `Parallel(t)` instead of `With(t)`; this is equivalent +> to calling `With(t)` followed by `t.Parallel()`. -There is no cleanup required after calling `With(t)`; the test frame is automatically cleaned up -when the test completes. +There is no cleanup required after calling `With(t)` or `Parallel(t)`; the test frame is +automatically cleaned up when the test completes. -If you use the `blugnu/test` package functions for running table-driven tests or explicit subtests -the test frame stack is managed for you: +If you use the `blugnu/test` package functions for running subtests, the test frame +is managed for you: ```go func TestDoSomething(t *testing.T) { @@ -148,9 +149,9 @@ the test frame stack is managed for you: } ``` -If a new test frame is created outside of the `test` package, then the `With(t)` function -must be called again to push that test frame onto the stack. For example, if you choose to create -a subtest using `*testing.T.Run()` and want to use the `blugnu/test` functions in that subtest: +However, if a new test frame is created outside of the `test` package, then `With(t)` must be +called to push that test frame onto the stack. For example, if you choose to create a subtest +using `*testing.T.Run()` and want to use the `blugnu/test` functions in that subtest: ```go func TestDoSomething(t *testing.T) { @@ -173,15 +174,15 @@ for each test function. ### Writing a Test: Expect -Almost all tests are written using `Expect` to create an expectation -over some value (the _subject_). `Expect` returns an _expectation_ with -methods for testing the subject. +Almost all tests are written using `Expect` to create an expectation for some value +(the _subject_). `Expect` returns an _expectation_ with methods for testing the subject. Some expectation methods test the value directly, such as `IsEmpty()`, `IsNil()` and `IsNotNil()`: ```go - err := DoSomething() + result, err := Sum(2, 2) Expect(err).IsNil() + Expect(result).To(Equal(4)) ``` ### Using Matchers @@ -322,7 +323,7 @@ is not possible. - [Testing Slices](#testing-slices) - [Testing Context](#testing-context) -## [Test Runners](#test-runner-functions) +## [Test Runners](#test-runners) - [Subtests](#subtests) - [Table-Driven Tests](#table-driven-tests) @@ -337,7 +338,7 @@ In addition to performing common, basic tests, the `test` package also provides | [Mocking Functions](#mocking-functions) | mock functions for testing | | [Recording Console Output](#recording-console-output) | record output of a function that writes to `stdout` and/or `stderr` | | [Test for an Expected Type](#test-for-an-expected-type) | test that a value is of an expected type | -| [Testing Test Helpers](#testing-test-helpers) | test your own test helper functions | +| [Testing a Test Helper](#testing-a-test-helper) | test your own test helper functions | ------
@@ -430,41 +431,84 @@ provided on expectations: ## Testing that an Error did not occur -There are two ways to explicitly test that an error did not occur: +There are three ways to test that an error did _not_ occur: ```go Expect(err).DidNotOccur() Expect(err).IsNil() + Expect(err).Is(nil) // useful in table-driven tests ``` -A third way to test that an error did not occur is to use the `Is()` method, passing `nil` -as the expected error: - -```go - Expect(err).Is(nil) -``` - -This is most useful when testing an error in a table driven test where each test case +The last form is particularly useful in table-driven tests where each test case may have an expected error or `nil`: ```go - Expect(err).Is(tc.err) // tc.err may be nil or an expected error + Expect(err).Is(tc.err) // tc.err may be nil or some expected error ``` ## Testing that an Error occurred (any error) +There are two ways to test that some error _did_ occur without specifying +a specific error or error type: + ```go Expect(err).DidOccur() Expect(err).IsNotNil() ``` +> _It is generally **not** recommended to test errors in this way as the test_ +> _is vague and vulnerable to false positives; but it may be useful or even_ +> _necessary in some cases_. + ## Testing that a Specific Error Occurred +There are three ways to test that a specific error or an error of a specific type +_did_ occur: + +```go + // an expected error value (errors.Is) + Expect(err).Is(someExpectedError) + + // an expected error type (errors.As) + v, ok := Expect(err).To(BeError[E]()) + v, ok := expect.Error[E](err) +``` + +The first form uses `errors.Is()` to determine whether the error matches the +expected error. + +The other forms test for a specific error type, using `errors.As()` where the +type parameter `E` is the expected error type. The tests will pass if the error +is of type `E` (or wraps an error of that type), returning the matched error +value of type `E` and a boolean indicating whether the test passed. + +If the test fails the indicator will be false and the matched value will be +the zero value of `E`. + +## Expected vs Required Errors + +If an expected error is critical to a test, further test execution may be +short-circuited by passing the `opt.IsRequired(true)` option: + +```go + Expect(err).IsNil(opt.Required()) +``` + +Alternatively, the `Require()` function may be used to create the expectation: + +```go + Require(err).IsNil() +``` + +Finally, for the generic form of testing for an expected error type, the +`require` package provides a short-circuit variant of the `Error[E]()` test: + ```go - Expect(err).Is(expectedError) // passes if `errors.Is(err, expectedError)` is true + v := require.Error[E](err) ``` -> _If `nil` is passed as the expected error, the test is equivalent to `IsNil()`_. +Since the test will _only_ return if the test passes, the function only returns +the matched error of type `E`. No boolean indicator is returned in this case. # Testing for Panics @@ -645,16 +689,16 @@ By contrast: ## Any-Matchers -An any-matcher is a matcher that accepts `any` as the subject type, allowing it to be used -with literally any value. Any-matchers are used with the `Should()` or `ShouldNot()` matching -methods. +An any-matcher is a matcher that accepts `any` as the subject type, allowing it +to be used with literally any value. Any-matchers are used with the `Should()` +or `ShouldNot()` matching methods. > Any-matchers may also be used with the `To()` or `ToNot()` matching methods -> if the formal type of the subject is `any`, but this is not recommended. +> but only if the formal type of the subject is `any`; this is not recommended. # Built-In Matchers -A number of matchers are provided in the `test` package, including: +A number of matchers are provided, including: | Factory Function | Subject Type | Description | @@ -678,7 +722,7 @@ A number of matchers are provided in the `test` package, including: | `HaveContextValue(K,V)` | `context.Context` | Tests that the context contains the expected key and value | -Matchers are used by passing the matcher to one of th expectation matching methods together +Matchers are used by passing the matcher to one of the expectation matching methods together with options to control the behaviour of the expectation or the matcher itself. A matcher is typically constructed by a factory function accepting any arguments required @@ -721,17 +765,17 @@ Options supported by matching methods (and therefore _all_ matchers) include: | `opt.OnFailure(string)` | a string to use as the error report for the test failure; this overrides the default error report for the matcher | | `opt.AsDeclaration(bool)` | a boolean to indicate whether values (other than strings) in test failure reports should be formatted as declarations (`%#v` rather than `%v`) | | `opt.QuotedStrings(bool)` | a boolean to indicate whether string values should be quoted in failure reports; defaults to `true` | -| `opt.IsRequired(bool)` | a boolean to indicate whether the expectation is required; defaults to `false` | +| `opt.IsRequired(bool)` | a boolean to indicate whether the expectation is required, short-circuiting the current test on failure; defaults to `false` | > `opt.OnFailure()` is a convenience function that returns an `opt.FailureReport` with a > function that returns the specified string in the report. > -> `opt.FailureReport` and `opt.OnFailure()` are mutually exclusive; if both are specified, only the -> first in the options list will be used. +> If both `opt.FailureReport` and `opt.OnFailure()` are specified or multiple options +> are provided of either type, only the first such option will be used. -The `...any` argument to an `opt.FailureReport` function is used to pass any options supplied to the -matcher, so that the error report can respect those options where appropriate. +The `...any` argument to an `opt.FailureReport` function is used to pass any options +supplied to the matcher, so that the error report can respect those options where appropriate. > See the [Custom Failure Report Guide](.assets/readme/custom-failure-reports.md) for details. @@ -862,7 +906,11 @@ expected by the matcher must be cast to that type: ------
-# Test Runner Functions +# Test Runners + +- [Subtests](#subtests) +- [Table-Driven Tests](#table-driven-tests) +- [Flaky Tests](#flaky-tests) The `testing.T` type is the standard test runner in Go. This type also provides a way to run subtests using the `test.Run()` function, which @@ -970,7 +1018,7 @@ There are two ways to skip/debug test cases: ```go - type TestCase struct { + type testcase struct { // fields... skip bool // set true to skip this test case debug bool // set true to debug this test case @@ -978,27 +1026,27 @@ There are two ways to skip/debug test cases: // skip a test case Run(Testcases( - ForEach(func(tc TestCase) { + ForEach(func(tc testcase) { // test code here }), - Skip("first case", TestCase{...debug: true}), // this test case will be skipped; the `debug` field is overridden by the Skip() function - Case("second case", TestCase{...}), + Skip("first case", testcase{...debug: true}), // this test case will be skipped; the `debug` field is overridden by the Skip() function + Case("second case", testcase{...}), )) // debug a test case Run(Testcases( - ForEach(func(tc TestCase) { + ForEach(func(tc testcase) { // test code here }), - Debug("first case", TestCase{... skip: true}), // ONLY this test case will be run; the 'skip' field is overridden by the Debug() function - Case("second case", TestCase{...}), + Debug("first case", testcase{... skip: true}), // ONLY this test case will be run; the 'skip' field is overridden by the Debug() function + Case("second case", testcase{...}), )) ``` ## Flaky Tests -Flaky tests are tests that may fail intermittently, often due to timing issues +Flaky tests are tests that may fail intermittently, due to timing issues or other non-deterministic factors. The `test` package provides a way to run flaky tests using the `FlakyTest()` runner. @@ -1010,9 +1058,15 @@ maximum number of attempts and/or until a maximum elapsed time has passed If the test passes before the maximum number of attempts or elapsed time is reached, the test passes; any failed attempts are ignored. -If the test fails on all attempts, the test fails, and the outcome of each attempt +If the test fails on all attempts, the test fails and the outcome of each attempt is reported in the test output. +## Helper Test Scenarios + +The `HelperTests()` runner is a specialised runner for testing a test helper +or custom matcher. See the [Running Multiple Helper Test Scenarios](#running-multiple-helper-test-scenarios) +section for details. + ------
@@ -1137,7 +1191,7 @@ outcome as an argument to the `R.Expect()` method: /* your test code here */ }) - result.Expect(TestPassed) + result.Expect(test.Passed) ``` ## Testing Test Helper Output @@ -1160,24 +1214,38 @@ method: > :bulb: By testing the output, the test is implicitly expected to fail, so the > `R.Expect()` method in this case will also test that the outcome is `TestFailed`. -## Running Multiple Test Scenarios +## Running Multiple Helper Test Scenarios -A specialised version of `RunScenarios()` is provided to test a test helper or -custom matcher: `RunTestScenarios()`. This accepts a slice of `TestScenario` values, -where each scenario is a test case to be run against your test helper or matcher. +The `HelperTests()` test runner is provided to run multiple test scenarios for testing +a test helper or custom matcher. The runner accepts a slice of `HelperScenario` +test cases: + +```go + Run(HelperTests([]HelperScenario{ + { + Scenario: "test scenario 1", + Act: func() { /* test code */ }, + Assert: func(r *R) { /* assertions */ }, + }, + { + Scenario: "test scenario 2", + Act: func() { /* test code */ }, + Assert: func(r *R) { /* assertions */ }, + }, + }...)) +``` -`RunTestScenarios()` implements a test runner function for you, so all you need -to do is provide a slice of scenarios, with each scenario consisting of: +Each scenario consists of: - `Scenario`: a name for the scenario (scenario); each scenario is run in a subtest using this name; -- `Act`: a function that contains the test code for the scenario; this function has - the signature `func()`; +- `Act`: a function that contains the test code for the scenario; - `Assert`: a function that tests the test outcome; this function has the signature `func(*R)` where `R` is the result of the test scenario. -The `Assert` function is optional; if not provided the scenario is one where the -test is expected to pass without any errors or failures. +The `Assert` function is optional; if not provided the scenario represents a case +where the test is expected to pass without any errors or failures and producing no +test output. ### Debugging and Skipping Scenarios @@ -1186,8 +1254,9 @@ subset, or specific test, or to ignore scenarios that are not yet implemented or known to be failing with problems which you wish to ignore while focussing on other scenarios. -The `RunTestScenarios()` function and `TestScenario` type support this by -providing a `Skip` and `Debug` field on each `TestScenario`. +Similar to the table-driven test runners, this can be accomplished by +skipping scenarios or marking them for debugging using the `Debug` and `Skip` +fields of the `HelperScenario` struct. > :warning:   When setting either `Debug` or `Skip` to `true`, it is important to remember to remove those settings when you are ready to move on to the next @@ -1202,7 +1271,7 @@ focus of your testing. }, { Scenario: "test scenario 2", - Skip: true, // <== this scenario won't run + Skip: true, // <== this scenario won't run Act: func() { /* test code */ }, Assert: func(r *R) { /* assertions */ }, }, @@ -1213,14 +1282,14 @@ Setting `Skip` to `true` may be impractical if you have a large number of scenar and want to run only a few of them. In this case, you can use the `Debug` field to focus on a single scenario or a subset of scenarios. -## Debugging Scenarios +### Debugging Scenarios > :bulb:   Setting `Debug` does not invoke the debugger or subject a test scenario to > any special treatment, beyond selectively running it. The name merely reflects that it > most likely to be of use when debugging. When `Debug` is set to `true` on any one or more scenarios, the test runner will run -ONLY those scenarios, skipping all other scenarios: +**ONLY** those scenarios, skipping all other scenarios: ```go scenarios := []TestScenario{ @@ -1249,137 +1318,35 @@ ONLY those scenarios, skipping all other scenarios: # Test for an Expected Type -You can test that some value is of an expected type using the `ExpectType` function. - -This function returns the value as the expected type and `true` if the test passes; -otherwise the zero-value of the expected type is returned, with `false`. - -A common pattern when this type of test is useful is to assert the type of some -value and then perform additional tests on that value appropriate to the type: +There are two ways to test that some value is of an expected type: ```go -func TestDoSomething(t *testing.T) { - With(t) + // using the BeOfType matcher + Expect(value).Should(BeOfType[ExpectedType]()) - // ACT - result := DoSomething() - - // ASSERT - if cust, ok := ExpectType[Customer](result); ok { - // further assertions on cust (type: Customer) ... - } -} + // using the expect.Type function + v, ok := expect.Type[ExpectedType](value) ``` -This test can only be used to test that a value is of a type that can be expressed -through the type parameter on the generic function. +The `BeOfType[T]()` matcher is used with the `Should()` or `ShouldNot()` methods +to test that the subject is of the expected type `T`. When testing using this +matcher, the test will fail if the subject is not of the expected type. -For example, the following test will fail as an invalid test: +If you need to use the value as the expected type, you can use the +`expect.Type[T](value)` function to test that the value is of the expected type. +This will return the value as the expected type `T` and a boolean indicating whether +the test passed. -```go - type Counter interface { - Count() int - } - ExpectType[Counter](result) // INVALID TEST: cannot be used to test for interfaces -``` - ------- - -# Testing Test Helpers - -If you write your own test helper functions you should of course test them. This -is problematic when using `*testing.T` since when your test helper fails the test -that is testing your helper will also fail. - -`blugnu/test` addresses this by providing a `TestHelper()` function that runs -your test helper in a separate test runner, capturing the outcome and any report. -This allows the test helper to fail without affecting the outcome of the test that -is testing it, allowing that test to then assert the expected outcome. - -The `TestHelper()` function accepts a function that executes your test helper, -and returns a `test.R` value that contains information about the outcome of the test. - -The `R` type provides methods to test the outcome of the test, primarily this will -involve testing the helper (correctly) failed and produced an expected report. This -is accomplished by calling the `R.Expect()` method with the expected test report; -when an expected report is passed, the test is implicitly expected to fail, so the -`R.Expect()` method will also test that the outcome is `TestFailed`: - -```go -func TestMyTestHelper(t *testing.T) { - With(t) +If the test fails, the boolean will be `false` and the value will be the zero-value +of the expected type, `T`. - result := TestHelper(func() { - MyTestHelper() // this is the test helper being tested - }) - - result.Expect( - "expected failure message", // the expected failure message - ) -} -``` - -To test that the test helper passed, you can call the `R.Expect()` method -with the expected outcome: +If the failure of the test should cause the current test to fail immediately, you +can use the `require.Type[T](value)` function which will short-circuit the current +test if the value is not of the expected type, returning the value as the expected +type `T` if the test passes. ```go -func TestMyTestHelper(t *testing.T) { - With(t) - - result := TestHelper(func() { - MyTestHelper() // this is the test helper being tested - }) - - result.Expect(TestPassed) // the test helper is expected to pass -} -``` - -## Testing a Test Helper with Scenarios - -A test runner is provided to test a test helper in a variety of scenarios. -The runner defines a `HelperScenario` type for each scenario, which -contains the following fields: - -- `Scenario`: a name for the scenario (scenario); each scenario is run in a subtest - using this name; -- `Act`: a function that contains the test code for the scenario; this function has - the signature `func()`; -- `Assert`: a function that tests the test outcome; this function has the signature - `func(*R)` where `R` is the result of the test scenario. -- `Skip`: a boolean to indicate whether the scenario should be skipped; defaults to `false` -- `Debug`: a boolean to indicate whether the scenario is a debug scenario; defaults to `false` - -> :bulb: If the `Debug` field is set to `true` on any scenario(s), the test runner -> will run only those scenarios, and these will be run even if `Skip` is also true. - -For scenarios where the helper is expected to pass, the `Assert` function -is optional; if the `Assert` function is nil, the test runner will assert -that the test passed without any errors or failures. - -The `HelperTests()` test runner accepts a variadic list of `HelperScenario` values, -where each scenario is a test case to be run against your test helper: - -```go -func TestMyTestHelper(t *testing.T) { - With(t) - - Run(HelperTests([]test.HelperScenario{ - {Scenario: "provided with an empty string", - Act: func() { - MyTestHelper("") - }, - Assert: func(r *test.R) { - r.ExpectInvalid("input string cannot be empty") - }, - }, - {Scenario: "this is expected to pass", - Act: func() { - MyTestHelper("some input") - }, - }, - }...)) -} + v := require.Type[ExpectedType](value) // short-circuits the current test if value is not of type ExpectedType ``` -> :bulb: In the example above, the scenarios are provided as a slice literal -> using the `...` operator to expand the slice into a variadic argument list. +Since the current test is halted on failure, an indicator boolean is not returned. diff --git a/bool.go b/bool.go deleted file mode 100644 index cb59784..0000000 --- a/bool.go +++ /dev/null @@ -1,65 +0,0 @@ -package test - -import "github.com/blugnu/test/matchers/bools" - -// BeFalse returns a matcher that will fail if the matched value is not false. -// -// # Supported Options -// -// opt.FailureReport(...) // a function returning a custom failure report -// // in the event that the test fails -func BeFalse() bools.BooleanMatcher { - return bools.BooleanMatcher{Expected: false} -} - -// BeTrue returns a matcher that will fail if the matched value is not true. -// -// # Supported Options -// -// opt.FailureReport(...) // a function returning a custom failure report -// // in the event that the test fails -func BeTrue() bools.BooleanMatcher { - return bools.BooleanMatcher{Expected: true} -} - -// ExpectFalse fails a test if a specified bool is not false. An optional -// name (string) may be specified to be included in the test report in the -// event of failure. -// -// This test is a convenience for these equivalent alternatives: -// -// Expect(got).To(Equal(false)) -// Expect(got).To(BeFalse()) -// -// # Supported Options -// -// string // a name for the value, for use in any test -// // failure report -// -// opt.FailureReport(func) // a function returning a custom failure report -// // in the event that the test fails -func ExpectFalse[T ~bool](got T, opts ...any) { - GetT().Helper() - Expect(bool(got), opts...).To(BeFalse(), opts...) -} - -// ExpectTrue fails a test if a specified bool is not true. An optional -// name (string) may be specified to be included in the test report in the -// event of failure. -// -// This test is a convenience for these equivalent alternatives: -// -// Expect(got).To(Equal(true)) -// Expect(got).To(BeTrue()) -// -// # Supported Options -// -// string // a name for the value, for use in any test -// // failure report -// -// opt.FailureReport(func) // a function returning a custom failure report -// // in the event that the test fails -func ExpectTrue[T ~bool](got T, opts ...any) { - GetT().Helper() - Expect(bool(got), opts...).To(BeTrue(), opts...) -} diff --git a/context_examples_test.go b/context_examples_test.go deleted file mode 100644 index 4b6ccfe..0000000 --- a/context_examples_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package test //nolint: testpackage // examples for test package reference the test package in expected output - -import ( - "context" - - "github.com/blugnu/test/test" -) - -func ExampleHaveContextKey() { - test.Example() - - type key int - ctx := context.WithValue(context.Background(), key(57), "varieties") - - // these tests will pass - Expect(ctx).To(HaveContextKey(key(57))) - Expect(ctx).ToNot(HaveContextKey(key(58))) - - // this test will fail - Expect(ctx).To(HaveContextKey(key(58))) - - // Output: - // expected key: test.key(58) - // key not present in context -} - -func ExampleHaveContextValue() { - // this is needed to make the example work; this would be usually - // be `With(t)` where `t` is the *testing.T - test.Example() - - type key int - ctx := context.WithValue(context.Background(), key(57), "varieties") - - // these tests will pass - Expect(ctx).To(HaveContextValue(key(57), "varieties")) - Expect(ctx).ToNot(HaveContextValue(key(56), "varieties")) - Expect(ctx).ToNot(HaveContextValue(key(57), "flavours")) - - // this test will fail - Expect(ctx).To(HaveContextValue(key(57), "flavours")) - - // Output: - // context value: test.key(57) - // expected: "flavours" - // got : "varieties" -} diff --git a/context_test.go b/context_test.go deleted file mode 100644 index e0ca785..0000000 --- a/context_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package test_test - -import ( - "context" - "testing" - - . "github.com/blugnu/test" - "github.com/blugnu/test/opt" -) - -func TestContext(t *testing.T) { - With(t) - - type key string - ctx := context.WithValue(context.Background(), key("key"), "value") - - Run( - Test("ContextKey", func() { - Run(HelperTests([]HelperScenario{ - {Scenario: "expected key present", - Act: func() { - Expect(ctx).To(HaveContextKey(key("key"))) - }, - }, - {Scenario: "expected key not present", - Act: func() { - Expect(ctx).To(HaveContextKey(key("other-key"))) - }, - Assert: func(result *R) { - result.Expect(TestFailed, opt.IgnoreReport(true)) - }, - }, - }...)) - })) - - Run(Test("ContextValue", func() { - Run(HelperTests([]HelperScenario{ - {Scenario: "expected value present", - Act: func() { - Expect(ctx).To(HaveContextValue(key("key"), "value")) - }, - }, - {Scenario: "expected value not present", - Act: func() { - Expect(ctx).To(HaveContextValue(key("other-key"), "value")) - }, - Assert: func(result *R) { - result.Expect(TestFailed, opt.IgnoreReport(true)) - }, - }, - {Scenario: "expected value present but different", - Act: func() { - Expect(ctx).To(HaveContextValue(key("key"), "other value")) - }, - Assert: func(result *R) { - result.Expect(TestFailed, opt.IgnoreReport(true)) - }, - }, - }...)) - }), - ) -} diff --git a/emptiness_test.go b/emptiness_test.go deleted file mode 100644 index c0a89be..0000000 --- a/emptiness_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package test_test - -import ( - "testing" - - . "github.com/blugnu/test" -) - -func TestEmptiness(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "empty slice is empty", - Act: func() { - Expect([]int{}).Should(BeEmpty()) - }, - }, - {Scenario: "nil slice is empty", - Act: func() { - var nilSlice []int - Expect(nilSlice).Should(BeEmptyOrNil()) - }, - }, - {Scenario: "nil slice has len 0", - Act: func() { - var nilSlice []int - Expect(nilSlice).Should(HaveLen(0)) - }, - }, - }...)) -} diff --git a/error.go b/error.go deleted file mode 100644 index b7320d2..0000000 --- a/error.go +++ /dev/null @@ -1,29 +0,0 @@ -package test - -import ( - "fmt" - - "github.com/blugnu/test/opt" -) - -// Error explicitly and unconditionally fails the current test -// with the given message. -// -// This should not be confused with the `test.Error` function -// used to report an error condition in a test helper (from the -// blugnu/test/test package). -func Error(msg string) { - T().Helper() - Expect(false).To(BeTrue(), opt.OnFailure(msg)) -} - -// Errorf explicitly and unconditionally fails the current test -// with the formatted message. -// -// This should not be confused with the `test.Error` function -// used to report an error condition in a test helper (from the -// blugnu/test/test package). -func Errorf(s string, args ...any) { - T().Helper() - Expect(false).To(BeTrue(), opt.OnFailure(fmt.Sprintf(s, args...))) -} diff --git a/error_test.go b/error_test.go deleted file mode 100644 index 4ac8fa3..0000000 --- a/error_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package test_test - -import ( - "testing" - - . "github.com/blugnu/test" -) - -func TestError(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "Error fails the test with the given message", - Act: func() { - Error("test error message") - }, - Assert: func(result *R) { - result.Expect("test error message") - }, - }, - {Scenario: "Errorf fails the test with a formatted message", - Act: func() { - Errorf("test error message %d", 42) - }, - Assert: func(result *R) { - result.Expect("test error message 42") - }, - }, - }...)) -} diff --git a/errors.go b/errors.go index 7285d50..b19bb06 100644 --- a/errors.go +++ b/errors.go @@ -4,22 +4,56 @@ import ( "errors" ) +// general errors var ( - // general errors - ErrInvalidArgument = errors.New("invalid argument") + // ErrInvalidArgument indicates that an argument provided to a function or method is invalid. + ErrInvalidArgument = errors.New("invalid argument") + + // ErrInvalidOperation indicates that an attempted operation is invalid in the current context. ErrInvalidOperation = errors.New("invalid operation") +) - // mock and fake errors +// mock and fake errors +var ( + // ErrExpectationsNotMet indicates that the expectations set on a mock or fake + // were not met. ErrExpectationsNotMet = errors.New("expectations not met") - ErrExpectedArgs = errors.New("arguments were expected but not recorded") - ErrNoResultForArgs = errors.New("no result for arguments") - ErrUnexpectedArgs = errors.New("the arguments recorded did not match those expected") - ErrUnexpectedCall = errors.New("unexpected call") - ErrResultNotUsed = errors.New("result not used") - - // recording errors - ErrRecordingFailed = errors.New("recording failed") - ErrRecordingStdout = errors.New("error recording stdout") - ErrRecordingStderr = errors.New("error recording stderr") - ErrRecordingUnableToRedirectLogger = errors.New("unable to redirect logger output") + + // ErrExpectedArgs indicates that arguments were expected but not recorded. + ErrExpectedArgs = errors.New("arguments were expected but not recorded") + + // ErrNoResultForArgs indicates that no result was found for the given arguments. + ErrNoResultForArgs = errors.New("no result for arguments") + + // ErrUnexpectedArgs indicates that the arguments recorded did not match those expected. + ErrUnexpectedArgs = errors.New("the arguments recorded did not match those expected") + + // ErrUnexpectedCall indicates that a call was made that was not expected. + ErrUnexpectedCall = errors.New("unexpected call") + + // ErrResultNotUsed indicates that a result was provided but not used. + ErrResultNotUsed = errors.New("result not used") +) + +// recording errors +var ( + // ErrRecordingFailed indicates that an error occurred while trying to + // record output. + ErrRecordingFailed = errors.New("recording failed") + + // ErrRecordingStdout indicates that an error occurred while trying to + // record stdout. + ErrRecordingStdout = errors.New("error recording stdout") + + // ErrRecordingStderr indicates that an error occurred while trying to + // record stderr. + ErrRecordingStderr = errors.New("error recording stderr") + + // ErrFailedToRedirectLogger indicates that an error occurred while trying + // to redirect logger output. + ErrFailedToRedirectLogger = errors.New("failed to redirect logger output") + + // Deprecated: this error is deprecated and will be removed in a future version; + // use [ErrFailedToRedirectLogger] instead. + ErrRecordingUnableToRedirectLogger = ErrFailedToRedirectLogger ) diff --git a/expect/bool.go b/expect/bool.go new file mode 100644 index 0000000..4597482 --- /dev/null +++ b/expect/bool.go @@ -0,0 +1,45 @@ +package expect + +import "github.com/blugnu/test" + +// False fails a test if a specified bool is not false. An optional +// name (string) may be specified to be included in the test report in the +// event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Expect(got).To(Equal(false)) +// Expect(got).To(BeFalse()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func False[T ~bool](got T, opts ...any) { + test.T().Helper() + test.Expect(bool(got), opts...).To(test.BeFalse(), opts...) +} + +// True fails a test if a specified bool is not true. An optional +// name (string) may be specified to be included in the test report in the +// event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Expect(got).To(Equal(true)) +// Expect(got).To(BeTrue()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func True[T ~bool](got T, opts ...any) { + test.T().Helper() + test.Expect(bool(got), opts...).To(test.BeTrue(), opts...) +} diff --git a/expect/bool_test.go b/expect/bool_test.go new file mode 100644 index 0000000..23d8e21 --- /dev/null +++ b/expect/bool_test.go @@ -0,0 +1,36 @@ +package expect_test + +import ( + "github.com/blugnu/test/expect" + "github.com/blugnu/test/test" +) + +func ExampleFalse() { + test.Example() + + var a = 1 + + expect.False(a == 1) // will fail + expect.False(a-1+1 == 1) // will also fail + expect.False(a == 2) // will pass + + // Output: + // expected false + // + // expected false +} + +func ExampleTrue() { + test.Example() + + var a = 1 + + expect.True(a == 1) // will pass + expect.True(a == 2) // will fail + expect.True(a == 3) // will also fail + + // Output: + // expected true + // + // expected true +} diff --git a/expect/error.go b/expect/error.go new file mode 100644 index 0000000..4fd250e --- /dev/null +++ b/expect/error.go @@ -0,0 +1,55 @@ +package expect + +import ( + "errors" + + "github.com/blugnu/test/internal" + "github.com/blugnu/test/opt" + + "github.com/blugnu/test" +) + +// Error tests that an error is of an expected type E, using [errors.As]. +// An ok indicator is returned indicating whether the test passed (true) +// or failed (false). If the test passes, the error is returned as a +// value of type E, otherwise the zero value of E is returned and should +// be ignored. +// +// If a test has no use for the returned E value, consider using the +// [BeError] matcher with [Expect]. +// +// If the test can only continue when the error is of the expected type, +// consider using the [require.Error] function to halt test execution +// when the error is not of the expected type. +// +// Expect(err).Should(BeError[E]()) // if the E value is not needed +// +// e := require.Error[E](err) // if the E value is necessary to the +// // continuation of the test +func Error[E error](got error, opts ...any) (E, bool) { + test.T().Helper() + + var target E + if got != nil && errors.As(got, &target) { + return target, true + } + + if _, ok := opt.Get[opt.FailureReport](opts); !ok { + opts = append(opts, + opt.FailureReport(func(...any) []string { + expectedType := internal.TypeName[E]() + if got == nil { + return []string{ + "expected error: " + expectedType, + "got : nil", + } + } + return []string{"expected error: " + expectedType} + }), + ) + } + + test.Fail(opts...) + + return target, false +} diff --git a/expect/error_test.go b/expect/error_test.go new file mode 100644 index 0000000..62a202b --- /dev/null +++ b/expect/error_test.go @@ -0,0 +1,72 @@ +package expect_test + +import ( + "errors" + "fmt" + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/expect" +) + +type MyError struct { + msg string +} + +func (e MyError) Error() string { + return e.msg +} + +func TestError(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "error is of expected type", + Act: func() { + err := fmt.Errorf("an error occurred: %w", MyError{"invalid argument"}) + e, ok := expect.Error[MyError](err) + + Expect(ok).To(BeTrue()) + Expect(e).To(Equal(MyError{"invalid argument"})) + }, + }, + + {Scenario: "error is not of expected type", + Act: func() { + err := errors.New("some other error") + e, ok := expect.Error[MyError](err) + Expect(ok).To(BeFalse()) + Expect(e).To(Equal(MyError{})) // zero value of MyError + + Error("subsequent failures should appear in report") + }, + Assert: func(result *R) { + result.Expect( + "expected error: expect_test.MyError", + ) + result.Expect( + "subsequent failures should appear in report", + ) + }, + }, + + {Scenario: "nil should not be identified as any error", + Act: func() { + e, ok := expect.Error[error](error(nil)) + Expect(ok).To(BeFalse()) + Expect(e).IsNil() + + Error("subsequent failures should appear in report") + }, + Assert: func(result *R) { + result.Expect( + "expected error: error", + "got : nil", + ) + result.Expect( + "subsequent failures should appear in report", + ) + }, + }, + }...)) +} diff --git a/expect/nil.go b/expect/nil.go new file mode 100644 index 0000000..09d5cae --- /dev/null +++ b/expect/nil.go @@ -0,0 +1,45 @@ +package expect + +import "github.com/blugnu/test" + +// Nil fails a test if a specified value is not nil. An optional +// name (string) may be specified to be included in the test report in the +// event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Expect(got).Is(nil) +// Expect(got).Should(BeNil()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func Nil(got any, opts ...any) { + test.T().Helper() + test.Expect(got, opts...).Should(test.BeNil(), opts...) +} + +// NotNil fails a test if a specified value is nil. An optional +// name (string) may be specified to be included in the test report in the +// event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Expect(got).IsNot(nil) +// Expect(got).ShouldNot(BeNil()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func NotNil(got any, opts ...any) { + test.T().Helper() + test.Expect(got, opts...).ShouldNot(test.BeNil(), opts...) +} diff --git a/expect/nil_test.go b/expect/nil_test.go new file mode 100644 index 0000000..f88a398 --- /dev/null +++ b/expect/nil_test.go @@ -0,0 +1,39 @@ +package expect_test + +import ( + "errors" + + "github.com/blugnu/test/expect" + "github.com/blugnu/test/test" +) + +func ExampleNil() { + test.Example() + + err := errors.New("an error") + expect.Nil(nil) + expect.Nil(err) // this test will fail + + // this test will fail as invalid (int is not nilable) + expect.Nil(42) + + // Output: + // expected nil, got error: an error + // <== INVALID TEST + // nilness.Matcher: values of type 'int' are not nilable +} + +func ExampleNotNil() { + test.Example() + + err := errors.New("an error") + expect.NotNil(nil) // this test will fail + expect.NotNil(err) // this test is valid and will pass (error is nilable) + + // this test is valid and will also pass. Non-nilable types are + // inherently "not nil"; the test is unnecessary, but not invalid + expect.NotNil(42) + + // Output: + // expected not nil +} diff --git a/expect/pkgdoc.go b/expect/pkgdoc.go new file mode 100644 index 0000000..1f73ce2 --- /dev/null +++ b/expect/pkgdoc.go @@ -0,0 +1,6 @@ +// Package expect provides conclusory type assertion functions that operate on a +// subject value. If the assertion fails, the test is failed though further expectations +// in the test will be evaluated. +package expect + +// this file is provided for package documentation purposes and is intentionally left blank diff --git a/expect/type.go b/expect/type.go new file mode 100644 index 0000000..02be3f7 --- /dev/null +++ b/expect/type.go @@ -0,0 +1,51 @@ +package expect + +import ( + "github.com/blugnu/test" + "github.com/blugnu/test/internal" + "github.com/blugnu/test/opt" +) + +// Type tests that a value is of an expected type T. If the test passes, +// the value is returned as that type, with true. If the test fails the zero +// value of the specified type is returned, with false. +// +// If a test has no use for the returned T value, consider using the +// [BeOfType] matcher with [Expect]. +// +// If the test can only continue when the value is of the expected type, +// consider using the [require.Type] function to halt test execution if +// when the value is not of the expected type. +// +// Expect(err).Should(BeOfType[T]()) // if the T value is not needed +// +// v := require.Type[T](got) // if the T value is necessary to the +// // continuation of the test +func Type[T any](got any, opts ...any) (T, bool) { + test.T().Helper() + + result, ok := got.(T) + if ok { + return result, true + } + + expectedType := internal.TypeName[T]() + gotType := internal.TypeName(got) + if got == nil { + gotType = "nil" + } + + opts = append(opts, + opt.FailureReport(func(...any) []string { + return []string{ + "expected type: " + expectedType, + "got : " + gotType, + } + }), + ) + + test.Expect(gotType, opts...).To(test.Equal(expectedType), opts...) + + var zero T + return zero, false +} diff --git a/expect/type_test.go b/expect/type_test.go new file mode 100644 index 0000000..9613f1f --- /dev/null +++ b/expect/type_test.go @@ -0,0 +1,128 @@ +package expect_test + +import ( + "errors" + "fmt" + "testing" + + . "github.com/blugnu/test" + + "github.com/blugnu/test/expect" + "github.com/blugnu/test/test" +) + +type Counter struct{} + +func (Counter) Count() int { + return 42 +} + +func TestExpectType(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "expecting int got int", + Act: func() { + result, ok := expect.Type[int](1) + Expect(result).To(Equal(1)) + Expect(ok).To(BeTrue()) + }, + }, + {Scenario: "expecting int got string", + Act: func() { + result, ok := expect.Type[int]("string") + Expect(result).To(Equal(0)) + Expect(ok).To(BeFalse()) + }, + Assert: func(result *R) { + result.Expect( + "expected type: int", + "got : string", + ) + }, + }, + {Scenario: "expecting named int got bool", + Act: func() { + result, ok := expect.Type[int](false, "named value") + Expect(result).To(Equal(0)) + Expect(ok).To(BeFalse()) + }, + Assert: func(result *R) { + result.Expect([]string{ + "named value:", + " expected type: int", + " got : bool", + }) + }, + }, + {Scenario: "expecting error, got nil", + Act: func() { + result, ok := expect.Type[error](nil) + Expect(ok).To(BeFalse()) + Expect(result).Should(BeNil()) + }, + Assert: func(result *R) { + result.Expect( + "expected type: error", + "got : nil", + ) + }, + }, + {Scenario: "expecting error, got error", + Act: func() { + result, ok := expect.Type[error](errors.New("an error occurred")) + Expect(ok).To(BeTrue()) + Expect(result.Error()).To(Equal("an error occurred")) + }, + }, + {Scenario: "expecting interface, got struct implementing interface", + Act: func() { + result, ok := expect.Type[interface{ Count() int }](Counter{}) + Expect(ok).To(BeTrue()) + Expect(result).IsNotNil() + }, + }, + {Scenario: "expecting interface, got struct not implementing interface", + Act: func() { + result, ok := expect.Type[error](Counter{}) + Expect(ok).To(BeFalse()) + Expect(result).IsNil() + }, + Assert: func(result *R) { + result.Expect( + "expected type: error", + "got : expect_test.Counter", + ) + }, + }, + }...)) +} + +func ExampleType() { + test.Example() + + // expect.Type returns the value as the expected type and true if the + // value is of that type + var got any = 1 / 2.0 + + result, ok := expect.Type[float64](got) + + fmt.Printf("ok is %v\n", ok) + fmt.Printf("result: type is: %T\n", result) + fmt.Printf("result: value is: %v\n", result) + + // expect.Type returns the zero value of the expected type and false if the + // value is not of that type (the return values can be ignored if the + // test is only concerned with checking the type) + got = "1 / 2.0" + + _, _ = expect.Type[float64](got) + + //Output: + // ok is true + // result: type is: float64 + // result: value is: 0.5 + // + // expected type: float64 + // got : string +} diff --git a/expect_didOccur.go b/expect_didOccur.go deleted file mode 100644 index c90b0b5..0000000 --- a/expect_didOccur.go +++ /dev/null @@ -1,169 +0,0 @@ -package test - -import ( - "fmt" - "runtime" - - "github.com/blugnu/test/matchers/panics" - "github.com/blugnu/test/opt" - "github.com/blugnu/test/test" -) - -// DidOccur is used to check whether an expected panic or error occurred. -// -// # Testing for Panics -// -// Use the Panic(r) function to create an expectation that a value r will -// be recovered from a panic. The call to DidOccur() must be deferred: -// -// defer Expect(Panic(r)).DidOccur(opts...) -// -// If the value r is an error the test will pass only if a panic occurs -// and an error is recovered from the panic that satisfies errors.Is(r). -// -// If the expected recovered value is not an error, the test passes if -// the recovered value is equal to the expected value, based on comparison -// using reflect.DeepEqual or a comparison function. -// -// # Supported Options -// -// func(a, b any) bool // a function to compare the values, overriding -// // the use of reflect.DeepEqual. -// -// # Testing for Errors -// -// To test for an error, use the error value as the expected value. -// The test will pass if the error is not nil: -// -// Expect(err).DidOccur() -// -// This is equivalent to: -// -// Expect(err).IsNotNil() -// -// NOTE: this approach to testing for errors is not recommended since -// the test will pass if any error occurred which may not be the error -// that was expected. This may be acceptable in very simple cases but -// it is usually better to test for a specific error using: -// -// Expect(err).Is(expectedError) -func (e expectation[T]) DidOccur(opts ...any) { - e.t.Helper() - - switch v := any(e.subject).(type) { - case panics.Expected: - match := &panics.MatchRecovered{R: recover()} - - if match.R != nil { - const bufsize = 65536 - stk := make([]byte, bufsize) - n := runtime.Stack(stk, false) - match.Stack = stk[:n-1] - } - - if !match.Match(v, opts...) { - e.fail(match, opts...) - } - - case error: - if v != nil { - return - } - - case nil: - e.err("expected error, got nil") - - default: - test.Invalid("test.DidOccur: may only be used with Panic() or error values") - } -} - -// DidNotOccur is used to ensure that a panic or error did not occur. -// -// # Testing for Panics -// -// Use the Panic() function to create an expectation for a Panic with -// an unspecified recovered value. The call to DidNotOccur() must be -// deferred: -// -// defer Expect(Panic()).DidNotOccur(opts...) -// -// The test will pass only if the function scope terminates without -// a panic having occurred. -// -// # Testing for Errors -// -// To test for an error, use the error value as the expected value. -// The test will pass if the error is nil: -// -// Expect(err).DidNotOccur() -// -// This is equivalent to: -// -// Expect(err).IsNil() -func (e expectation[T]) DidNotOccur(opts ...any) { - e.t.Helper() - - switch expected := any(e.subject).(type) { - case panics.Expected: - // for a "DidNotOccur" test, things are more complicated: - - // first let's grab any recoverable value and create a - // matcher which we'll use later... - matcher := &panics.MatchRecovered{R: recover()} - - // first, using DidNotOccur with Panic(nil) is invalid since it - // is likely to cause confusion - if expected.R == opt.NoPanicExpected(true) { - test.Invalid("DidNotOccur: may not be used with Panic(nil); did you mean NilPanic()?") - } - - // if we expect Panic(x) did NOT occur, but Panic(y) DID occur, - // then although the expectation was met, the UNexpected panic - // should still be reported as a test failure. - // - // so we use the MatchesPanic matcher to determine whether - // the recovered value matches the expected value... - recoveredExpectedValue := matcher.Match(expected, opts...) - - // if the recovered value matches the expected value, then - // the test has failed since this panic should not have occurred... - if recoveredExpectedValue && expected.R != nil { - // we add the ToNotMatch(true) option to indicate that the - // expectation was that the panic should not have occurred - e.fail(matcher, append(opts, opt.ToNotMatch(true))...) - return - } - - // but we're not done yet... - // - // the recovered value did not match the expected value, and if that - // recovered value is not nil, then we have an unexpected panic to report... - if matcher.R != nil { - // the existing matcher has already been used to test the recovered - // value against an expected value where-as we now need to report an - // unexpected panic (i.e. expected nil) - // - // so we create a new panic matcher, matching against an expected R:nil - // and use THAT to report the failure - matcher := &panics.MatchRecovered{R: matcher.R} - matcher.Match(panics.Expected{R: nil}) - e.fail(matcher, opts...) - } - - case error: - opts = append(opts, opt.FailureReport(func(opts ...any) []string { - return []string{ - "expected: ", - fmt.Sprintf("got : %T(%v)", expected, opt.ValueAsString(expected, opts...)), - } - })) - Expect(expected).IsNil(opts...) - - case nil: - return - - default: - test.Invalid("test.DidNotOccur: may only be used with Panic() or error values") - } -} diff --git a/expect_didOccur_test.go b/expect_didOccur_test.go deleted file mode 100644 index 0017120..0000000 --- a/expect_didOccur_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package test_test - -import ( - "errors" - "testing" - - . "github.com/blugnu/test" - "github.com/blugnu/test/opt" -) - -func TestExpect_DidOccur(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - // panics - {Scenario: "panic was expected and occurred", - Act: func() { - defer Expect(Panic(ErrInvalidArgument)).DidOccur() - panic(ErrInvalidArgument) - }, - }, - {Scenario: "panic was expected and did not occur", - Act: func() { - defer Expect(Panic(ErrInvalidArgument)).DidOccur() - }, - Assert: func(result *R) { - result.Expect(TestFailed, opt.IgnoreReport(true)) - }, - }, - - // errors - {Scenario: "error was expected and occurred", - Act: func() { Expect(errors.New("error")).DidOccur() }, - }, - {Scenario: "error was expected and did not occur", - Act: func() { - var err error - Expect(err).DidOccur() - }, - Assert: func(result *R) { - result.Expect("expected error, got nil") - }, - }, - - // unsupported types - {Scenario: "not an error or panic", - Act: func() { - Expect(42).DidOccur() - }, - Assert: func(result *R) { - result.ExpectInvalid( - "test.DidOccur: may only be used with Panic() or error values", - ) - }, - }, - }...)) -} - -func TestExpect_DidNotOccur(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - // panics - {Scenario: "panic(), no panic occurs", - Act: func() { - defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() - }, - }, - {Scenario: "panic(r), no panic occurs", - Act: func() { - defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() - }, - }, - {Scenario: "panic(nil)", - Act: func() { - defer Expect(Panic(nil)).DidNotOccur() - }, - Assert: func(result *R) { - result.ExpectInvalid( - "DidNotOccur: may not be used with Panic(nil); did you mean NilPanic()?", - ) - }, - }, - {Scenario: "panic was expected to not occur", - Act: func() { - defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() - panic(ErrInvalidArgument) - }, - Assert: func(result *R) { - result.Expect( - "expected: panic with *errors.errorString(invalid argument): should not have occurred", - ) - }, - }, - {Scenario: "panic was not expected and occurred", - Act: func() { - defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() - panic(ErrInvalidOperation) - }, - Assert: func(result *R) { - result.Expect( - "unexpected panic:", - " recovered: *errors.errorString(invalid operation)", - ) - }, - }, - - // errors - {Scenario: "error was not expected and did not occur", - Act: func() { var err error = nil; Expect(err).DidNotOccur() }, - }, - {Scenario: "error was not expected and occurred", - Act: func() { - Expect(errors.New("error")).DidNotOccur() - }, - Assert: func(result *R) { - result.Expect( - "expected: ", - `got : *errors.errorString(error)`, - ) - }, - }, - - // unsupported types - {Scenario: "not an error or panic", - Act: func() { - Expect(42).DidNotOccur() - }, - Assert: func(result *R) { - result.ExpectInvalid( - "test.DidNotOccur: may only be used with Panic() or error values", - ) - }, - }, - }...)) -} diff --git a/expect.go b/expectation.go similarity index 85% rename from expect.go rename to expectation.go index b15846b..79871c6 100644 --- a/expect.go +++ b/expectation.go @@ -4,52 +4,14 @@ import ( "errors" "fmt" "reflect" + "slices" + "strings" "github.com/blugnu/test/matchers/matcher" "github.com/blugnu/test/opt" "github.com/blugnu/test/test" ) -// AnyMatcher is the interface implemented by matchers that can test -// any type of value. It is used to apply matchers to expectations -// that are not type-specific. -// -// It is preferable to use the Matcher[T] interface for type-safe -// expectations; AnyMatcher is provided for situations where the -// compatible types for a matcher cannot be enforced at compile-time. -// -// When implementing an AnyMatcher, it is important to ensure that -// the matcher fails a test if it is not used correctly, i.e. if the -// matcher is not compatible with the type of the value being tested. -// -// An AnyMatcher must be used with the Expect().Should() matching -// function; they may also be used with Expect(got).To() where the got -// value is of type `any`, though this is not recommended. -type AnyMatcher interface { - Match(got any, opts ...any) bool -} - -// Matcher[T] is the interface implemented by matchers that can test -// a value of type T. It is used to apply matchers to expectations -// that are type-specific and type-safe. -// -// Note that not all type-safe matchers implement a generic interface; -// a matcher that implements Match(got X, opts ...any) bool, where X is -// a formal, literal type (i.e. not generic) is also a type-safe matcher. -// -// Matcher[T] describes the general form of a type-safe matcher. -// -// Generic matchers are able to leverage the type system to ensure -// that the matcher is used correctly with a variety of types, i.e. where -// the type of the Expect() value satisfies the constraints of the matcher -// type, T. The equals.Matcher[T comparable] uses this approach, for -// example, to ensure that the value being tested is comparable -// with the expected value (since the matcher uses the == operator for -// equality testing). -type Matcher[T any] interface { - Match(got T, opts ...any) bool -} - // Expect creates an expectation for the given value. The value // may be of any type. // @@ -142,18 +104,15 @@ func (e *expectation[T]) err(msg any) { errorFn(msg) case []string: - var rpt string - var indent string - + rpt := slices.Clone(msg) if e.name != "" { - rpt = "\n" + e.name + ":" - indent = " " + for i := range rpt { + rpt[i] = " " + rpt[i] + } + rpt = append([]string{e.name + ":"}, rpt...) } - for _, s := range msg { - rpt += "\n" + indent + s - } - errorFn(rpt) + errorFn("\n" + strings.Join(rpt, "\n")) // errMsg returns a string or []string, so we can safely use a type // switch here to handle both cases without a default case @@ -309,6 +268,141 @@ func (e *expectation[T]) getTestFailureReporter(opts ...any) any { return nil } +// Is tests the value of the expectation against some expected +// value. +// +// The function behaves differently depending on the values and +// types of the expected and actual values: +// +// - If both values are nil, the test passes; +// +// - If either value is nil and the other is not, the test fails; +// +// - If both values implement the error interface, the test passes +// if the error being tested satisfies errors.Is(expected); +// +// - Otherwise, the values are compared using reflect.DeepEqual +// or a comparison function supplied in the options; +// +// i.e. for non-nil, non-error values, an Is() test is equivalent to: +// +// Expect(got).To(DeepEqual(expected), opts...) +// +// and for error values, an Is() test is equivalent to: +// +// Expect(errors.Is(got, expected)).To(BeTrue(), opts...) +// +// # Supported Options +// +// func(a, b any) bool // a function to compare the values +// // (overriding the use of reflect.DeepEqual) +// +// opt.FailureReport(func) // a function that returns a custom test +// // failure report if the test fails. +// +// opt.OnFailure(string) // a string to output as the failure +// // report if the test fails. +func (e *expectation[T]) Is(expected T, opts ...any) { + e.t.Helper() + + switch { + case any(expected) == nil: + e.IsNil(opts...) + return + + case any(expected) != nil && any(e.subject) == nil: + if _, ok := opt.Get[opt.FailureReport](opts); !ok { + opts = append(opts, opt.OnFailure(fmt.Sprintf("expected %v, got nil", expected))) + } + Fail(opts...) + + default: + experr, _ := any(expected).(error) + goterr, _ := any(e.subject).(error) + if experr != nil && goterr != nil { + if _, ok := opt.Get[opt.FailureReport](opts); !ok { + opts = append(opts, opt.FailureReport(func(...any) []string { + return []string{ + fmt.Sprintf("expected error: %v", experr), + fmt.Sprintf("got : %v", goterr), + } + })) + } + + FailIfNot(errors.Is(goterr, experr), opts...) + return + } + + Expect(e.subject, e.name).To(DeepEqual(expected), opts...) + } +} + +// IsNot tests the value of the expectation against some expected +// value for a non-match. +// +// The test behaves differently depending on the value and type of +// the expected and actual values: +// +// - IsNot(nil) is equivalent to [expectation.IsNotNil]; +// +// - If either value is [nil] and the other is not, the test passes; +// +// - If both values implement the [error] interface, the test passes +// if the error being tested does not satisfy [errors.Is]; +// +// - Otherwise, the values are compared using [reflect.DeepEqual], +// or a comparison function supplied in the options; +// +// i.e. for non-nil, non-error values, an IsNot() test is equivalent to: +// +// Expect(got).ToNot(DeepEqual(expected), opts...) +// +// and for error values, an IsNot() test is equivalent to: +// +// Expect(errors.Is(got, expected)).To(BeFalse(), opts...) +// +// # Supported Options +// +// func(a, b any) bool // a function to compare the values +// // (overriding the use of reflect.DeepEqual) +// +// opt.FailureReport(func) // a function that returns a custom test +// // failure report if the test fails. +// +// opt.OnFailure(string) // a string to output as the failure +// // report if the test fails. +func (e *expectation[T]) IsNot(expected T, opts ...any) { + e.t.Helper() + + switch { + case (any(expected) == nil) != (any(e.subject) == nil): + return + + case any(expected) == nil: + e.IsNotNil(opts...) + return + + default: + experr, _ := any(expected).(error) + goterr, _ := any(e.subject).(error) + if experr != nil && goterr != nil { + if _, ok := opt.Get[opt.FailureReport](opts); !ok { + opts = append(opts, opt.FailureReport(func(...any) []string { + return []string{ + fmt.Sprintf("expected error that is not: %v", experr), + fmt.Sprintf("got : %v", goterr), + } + })) + } + + FailIf(errors.Is(goterr, experr), opts...) + return + } + + Expect(e.subject, e.name).ToNot(DeepEqual(expected), opts...) + } +} + // Should applies a matcher to the expectation. If the matcher // does not match the value, the test fails. // @@ -458,67 +552,3 @@ func (e *expectation[T]) ToNot(matcher matcher.ForType[T], opts ...any) { e.fail(matcher, opts...) } } - -// Is tests the value of the expectation against some expected -// value. -// -// The function behaves differently depending on the values and -// types of the expected and actual values: -// -// - If both values are nil, the test passes; -// -// - If either value is nil and the other is not, the test fails; -// -// - If both values implement the error interface, the test passes -// if the error being tested satisfies errors.Is(expected); -// -// - Otherwise, the values are compared using reflect.DeepEqual -// or a comparison function supplied in the options; -// -// i.e. for non-nil, non-error values, an Is() test is equivalent to: -// -// Expect(got).To(DeepEqual(expected), opts...) -// -// and for error values, an Is() test is equivalent to: -// -// Expect(errors.Is(got, expected)).To(BeTrue(), opts...) -// -// # Supported Options -// -// func(a, b any) bool // a function to compare the values -// // (overriding the use of reflect.DeepEqual) -// -// opt.FailureReport(func) // a function that returns a custom test -// // failure report if the test fails. -// -// opt.OnFailure(string) // a string to output as the failure -// // report if the test fails. -func (e *expectation[T]) Is(expected T, opts ...any) { - e.t.Helper() - - switch { - case any(expected) == nil: - e.IsNil() - return - - case any(expected) != nil && any(e.subject) == nil: - e.err(fmt.Sprintf("expected %v, got nil", expected)) - - default: - experr, _ := any(expected).(error) - goterr, _ := any(e.subject).(error) - if experr != nil && goterr != nil { - ExpectTrue( - errors.Is(goterr, experr), - opt.FailureReport(func(...any) []string { - return []string{ - fmt.Sprintf("expected error: %v", experr), - fmt.Sprintf("got : %v", goterr), - } - }), - ) - return - } - Expect(e.subject, e.name).To(DeepEqual(expected), opts...) - } -} diff --git a/expect_test.go b/expectation_test.go similarity index 67% rename from expect_test.go rename to expectation_test.go index 2aecadf..35092e5 100644 --- a/expect_test.go +++ b/expectation_test.go @@ -290,6 +290,133 @@ func TestExpect_TestFailureReporting(t *testing.T) { }...)) } +func TestExpect_DidOccur(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + // panics + {Scenario: "panic was expected and occurred", + Act: func() { + defer Expect(Panic(ErrInvalidArgument)).DidOccur() + panic(ErrInvalidArgument) + }, + }, + {Scenario: "panic was expected and did not occur", + Act: func() { + defer Expect(Panic(ErrInvalidArgument)).DidOccur() + }, + Assert: func(result *R) { + result.Expect(test.Failed, opt.IgnoreReport(true)) + }, + }, + + // errors + {Scenario: "error was expected and occurred", + Act: func() { Expect(errors.New("error")).DidOccur() }, + }, + {Scenario: "error was expected and did not occur", + Act: func() { + var err error + Expect(err).DidOccur() + }, + Assert: func(result *R) { + result.Expect("expected error, got nil") + }, + }, + + // unsupported types + {Scenario: "not an error or panic", + Act: func() { + Expect(42).DidOccur() + }, + Assert: func(result *R) { + result.ExpectInvalid( + "test.DidOccur: may only be used with Panic() or error values", + ) + }, + }, + }...)) +} + +func TestExpect_DidNotOccur(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + // panics + {Scenario: "panic(), no panic occurs", + Act: func() { + defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() + }, + }, + {Scenario: "panic(r), no panic occurs", + Act: func() { + defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() + }, + }, + {Scenario: "panic(nil)", + Act: func() { + defer Expect(Panic(nil)).DidNotOccur() + }, + Assert: func(result *R) { + result.ExpectInvalid( + "DidNotOccur: may not be used with Panic(nil); did you mean NilPanic()?", + ) + }, + }, + {Scenario: "panic was expected to not occur", + Act: func() { + defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() + panic(ErrInvalidArgument) + }, + Assert: func(result *R) { + result.Expect( + "expected: panic with *errors.errorString(invalid argument): should not have occurred", + ) + }, + }, + {Scenario: "panic was not expected and occurred", + Act: func() { + defer Expect(Panic(ErrInvalidArgument)).DidNotOccur() + panic(ErrInvalidOperation) + }, + Assert: func(result *R) { + result.Expect( + "unexpected panic:", + " recovered: *errors.errorString(invalid operation)", + ) + }, + }, + + // errors + {Scenario: "error was not expected and did not occur", + Act: func() { var err error = nil; Expect(err).DidNotOccur() }, + }, + {Scenario: "error was not expected and occurred", + Act: func() { + Expect(errors.New("error")).DidNotOccur() + }, + Assert: func(result *R) { + result.Expect( + "expected: ", + `got : *errors.errorString(error)`, + ) + }, + }, + + // unsupported types + {Scenario: "not an error or panic", + Act: func() { + Expect(42).DidNotOccur() + }, + Assert: func(result *R) { + result.ExpectInvalid( + "test.DidNotOccur: may only be used with Panic() or error values", + ) + }, + }, + }...)) +} + func TestExpect_Is(t *testing.T) { With(t) @@ -367,6 +494,79 @@ func TestExpect_Is(t *testing.T) { }...)) } +func TestExpect_IsNot(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + { + Scenario: "expecting not nil and got nil", + Act: func() { var a any; Expect(a).IsNot(nil) }, + Assert: func(result *R) { + result.Expect("expected not nil") + }, + }, + { + Scenario: "expecting not nil and got not nil", + Act: func() { var a any = 1; Expect(a).IsNot(nil) }, + }, + { + Scenario: "expecting not nil when got is of not nilable type", + Act: func() { var a any = 1; Expect(a).IsNot(nil) }, + }, + { + Scenario: "expected and got are unequal ints", + Act: func() { var a int; Expect(a).IsNot(1) }, + }, + { + Scenario: "expecting not nil error and got nil", + Act: func() { var err error; Expect(err).IsNot(nil) }, + Assert: func(result *R) { + result.Expect("expected not nil") + }, + }, + { + Scenario: "sentinel is not sentinel", + Act: func() { + sent := errors.New("sentinel") + Expect(sent).IsNot(sent) + }, + Assert: func(result *R) { + result.Expect( + "expected error that is not: sentinel", + ) + }, + }, + { + Scenario: "sentinel is not other sentinel", + Act: func() { + senta := errors.New("sentinel-a") + sentb := errors.New("sentinel-b") + Expect(senta).IsNot(sentb) + }, + }, + { + Scenario: "non-nil error is not nil", + Act: func() { + err := errors.New("error") + Expect(err).IsNot(nil) + }, + }, + { + Scenario: "struct is not equal struct", + Act: func() { Expect(struct{ a int }{a: 1}).IsNot(struct{ a int }{a: 1}) }, + Assert: func(result *R) { + result.Expect( + "expected to not equal: struct { a int }{a:1}", + ) + }, + }, + { + Scenario: "struct is not unequal struct", + Act: func() { Expect(struct{ a int }{a: 1}).IsNot(struct{ a int }{a: 2}) }, + }, + }...)) +} + func TestExpect_Should(t *testing.T) { With(t) @@ -385,7 +585,7 @@ func TestExpect_Should(t *testing.T) { Expect([]int{1}).Should(BeEmpty()) }, Assert: func(result *R) { - result.Expect(TestFailed, opt.IgnoreReport(true)) // testing the behaviour of Should(); matcher report is not significant + result.Expect(test.Failed, opt.IgnoreReport(true)) // testing the behaviour of Should(); matcher report is not significant }, }, }...)) @@ -409,7 +609,7 @@ func TestExpect_ShouldNot(t *testing.T) { Expect([]int{}).ShouldNot(BeEmpty()) }, Assert: func(result *R) { - result.Expect(TestFailed, opt.IgnoreReport(true)) // testing the behaviour of ToNot(); matcher report is not significant + result.Expect(test.Failed, opt.IgnoreReport(true)) // testing the behaviour of ToNot(); matcher report is not significant }, }, }...)) @@ -442,7 +642,7 @@ func TestExpect_ToNot(t *testing.T) { Expect(true).ToNot(Equal(true)) }, Assert: func(result *R) { - result.Expect(TestFailed, opt.IgnoreReport(true)) // testing the behaviour of ToNot(), not the output of the matcher used + result.Expect(test.Failed, opt.IgnoreReport(true)) // testing the behaviour of ToNot(), not the output of the matcher used }, }, }...)) @@ -459,6 +659,10 @@ func TestRequire(t *testing.T) { result.Expect("expected false, got true") } +// ============================================================================ +// MARK: examples +// ============================================================================ + func ExampleRequire() { test.Example() diff --git a/fail.go b/fail.go new file mode 100644 index 0000000..49c1707 --- /dev/null +++ b/fail.go @@ -0,0 +1,71 @@ +package test + +import ( + "fmt" + "strings" + + "github.com/blugnu/test/opt" +) + +// Error explicitly and unconditionally fails the current test +// with the given message. +// +// This should not be confused with the `test.Error` function +// used to report an error condition in a test helper (from the +// blugnu/test/test package). +func Error(msg string) { + T().Helper() + Fail(opt.OnFailure(msg)) +} + +// Errorf explicitly and unconditionally fails the current test +// with the formatted message. +// +// This should not be confused with the `test.Error` function +// used to report an error condition in a test helper (from the +// blugnu/test/test package). +func Errorf(s string, args ...any) { + T().Helper() + Fail(opt.OnFailure(fmt.Sprintf(s, args...))) +} + +// Fail explicitly and unconditionally fails the current test with the given +// options. +// +// If the supplied options do not contain an [opt.FailureReport], the test is +// failed with a "test failed" message. +func Fail(opts ...any) { + T().Helper() + FailIf(true, opts...) +} + +// FailIf fails the current test with the given options only if a specified +// condition is true. +func FailIf(cond bool, opts ...any) { + if !cond { + return + } + + T().Helper() + + if _, ok := opt.Get[opt.FailureReport](opts); !ok { + var msg string = "test failed" + + if s, ok := opt.Get[[]string](opts); ok { + msg = strings.Join(s, "\n") + } else if s, ok := opt.Get[string](opts); ok { + msg = s + } + + opts = append([]any{opt.OnFailure(msg)}, opts...) + } + + Expect(cond).To(BeFalse(), opts...) +} + +// FailIfNot fails the current test with the given options only if a specified +// condition is not true. +func FailIfNot(cond bool, opts ...any) { + T().Helper() + FailIf(!cond, opts...) +} diff --git a/fail_test.go b/fail_test.go new file mode 100644 index 0000000..add45cb --- /dev/null +++ b/fail_test.go @@ -0,0 +1,91 @@ +package test_test + +import ( + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/opt" +) + +func TestError(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "Error fails the test with the given message", + Act: func() { + Error("test error message") + }, + Assert: func(result *R) { + result.Expect("test error message") + }, + }, + {Scenario: "Errorf fails the test with a formatted message", + Act: func() { + Errorf("test error message %d", 42) + }, + Assert: func(result *R) { + result.Expect("test error message 42") + }, + }, + }...)) +} + +func TestFail(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "string message", + Act: func() { + Fail("it failed") + }, + Assert: func(result *R) { + result.Expect("it failed") + }, + }, + {Scenario: "multi-line failure report", + Act: func() { + Fail([]string{"it failed", "on multiple lines"}) + }, + Assert: func(result *R) { + result.Expect([]string{ + "it failed", + "on multiple lines", + }) + }, + }, + {Scenario: "no failure report", + Act: func() { + Fail() + }, + Assert: func(result *R) { + result.Expect("test failed") + }, + }, + {Scenario: "fail if condition is true", + Act: func() { + FailIf(true, "condition was true") + }, + Assert: func(result *R) { + result.Expect("condition was true") + }, + }, + {Scenario: "fail if condition is not true", + Act: func() { + FailIfNot(false, "condition was false") + }, + Assert: func(result *R) { + result.Expect("condition was false") + }, + }, + + {Scenario: "applies opt.Required", + Act: func() { + Fail(opt.Required(), "failed and is required") + Fail("this should not be evaluated") + }, + Assert: func(result *R) { + result.Expect("failed and is required") + }, + }, + }...)) +} diff --git a/internal/testcase/runner.go b/internal/testcase/runner.go index cd81dd2..e6fadfc 100644 --- a/internal/testcase/runner.go +++ b/internal/testcase/runner.go @@ -103,8 +103,7 @@ func (tcr *Runner[T]) AddCase(name string, tc T, flags ...Flags) { // Run runs the test cases in the runner. Each test case is run as a subtest // in the current test frame. The test case name is used to identify the test -// case in the test output, and any Before/After scaffolding functions are -// called with the test case data. +// case in the test output. func (tcr Runner[T]) Run() { t := tcr.TestingT t.Helper() diff --git a/internal/testcase/runner_test.go b/internal/testcase/runner_test.go index 06437c2..08a9f10 100644 --- a/internal/testcase/runner_test.go +++ b/internal/testcase/runner_test.go @@ -6,6 +6,7 @@ import ( . "github.com/blugnu/test" "github.com/blugnu/test/internal/testcase" + "github.com/blugnu/test/test" ) func TestNewRunner(t *testing.T) { @@ -133,6 +134,6 @@ func TestRunner_Run(t *testing.T) { Expect(executed).To(Equal(2)) }) - result.Expect(TestPassed) + result.Expect(test.Passed) })) } diff --git a/internal/testframe/errors.go b/internal/testframe/errors.go index 0e846e1..0f1f2b9 100644 --- a/internal/testframe/errors.go +++ b/internal/testframe/errors.go @@ -5,6 +5,7 @@ import "errors" var ( ErrEmptyStack = errors.New("empty stack for this goroutine") ErrNoStack = errors.New("error determining goroutine id: unable to obtain stack information for goroutine") + ErrNoCleanupFunction = errors.New("no cleanup function") ErrNoTestFrame = errors.New("no test frame; did you forget to call With(t)?") ErrUnexpectedStackFormat = errors.New("unable to determine goroutine id: unexpected stack format") ) diff --git a/internal/testframe/is-parallel.go b/internal/testframe/is-parallel.go new file mode 100644 index 0000000..eda49b7 --- /dev/null +++ b/internal/testframe/is-parallel.go @@ -0,0 +1,26 @@ +package testframe + +import ( + "reflect" + "testing" +) + +// IsParallel checks if the current test frame is running in parallel. +// +// The underlying type of the current test frame must be a *testing.T; +// any other implementation will return false. +// +// For *testing.T values, reflection is used to examine the internal +// state to determine if it is running in parallel. +func IsParallel() bool { + t, ok := Peek[*testing.T]() + if !ok { + // tests cannot be parallel if the TestingT is not a *testing.T + return false + } + + c := reflect.Indirect(reflect.ValueOf(t)).FieldByName("common") + ip := reflect.Indirect(c).FieldByName("isParallel") + + return ip.Bool() +} diff --git a/internal/testframe/is-parallel_test.go b/internal/testframe/is-parallel_test.go new file mode 100644 index 0000000..fe1fc96 --- /dev/null +++ b/internal/testframe/is-parallel_test.go @@ -0,0 +1,34 @@ +package testframe_test + +import ( + "testing" + + . "github.com/blugnu/test" + + "github.com/blugnu/test/internal/testframe" + "github.com/blugnu/test/test" +) + +func TestIsParallel(t *testing.T) { + With(t) + + Run(Test("not parallel", func() { + Expect(testframe.IsParallel()).To(BeFalse()) + T().Parallel() + Expect(testframe.IsParallel()).To(BeTrue()) + })) + + Run(Test("parallel", func() { + T().Parallel() + Expect(testframe.IsParallel()).To(BeTrue()) + })) + + Run(Test("example runner is always non-parallel", func() { + test.Example() + defer testframe.Pop() + + T().Parallel() // NO-OP in example mode + + Expect(testframe.IsParallel()).To(BeFalse()) + })) +} diff --git a/internal/testframe/stack.go b/internal/testframe/stack.go index 81bc88a..1f33b93 100644 --- a/internal/testframe/stack.go +++ b/internal/testframe/stack.go @@ -125,3 +125,18 @@ func Push(t any) { stk = append(stk, testframe{T: t}) stacks.frames[id] = stk } + +// PushWithCleanup adds a new test frame to the current goroutine's stack +// and registers a cleanup function to remove it when the test frame completes. +// +// The function is safe to call concurrently and will not modify the stack +// of other goroutines. +func PushWithCleanup(t any) { + c, ok := t.(interface{ Cleanup(func()) }) + if !ok { + panic(ErrNoCleanupFunction) + } + + Push(t) + c.Cleanup(Pop) +} diff --git a/internal/testframe/stack_test.go b/internal/testframe/stack_test.go index 648f5e2..95ddad9 100644 --- a/internal/testframe/stack_test.go +++ b/internal/testframe/stack_test.go @@ -188,3 +188,46 @@ func TestPush(t *testing.T) { t.Errorf("expected 1 stack, got %d", len(stacks.frames)) } } + +type mockT struct { + cleanupWasCalled bool +} + +func (m *mockT) Cleanup(f func()) { + m.cleanupWasCalled = true + f() +} + +func TestPushWithCleanup(t *testing.T) { + og := stacks.frames + defer func() { + stacks.frames = og + }() + + t.Run("cleanup called", func(t *testing.T) { + mock := &mockT{} + + // ensure we start with no stacks + stacks.frames = map[uintptr][]testframe{} + + PushWithCleanup(mock) + + if !mock.cleanupWasCalled { + t.Errorf("expected cleanup to be called") + } + }) + + t.Run("called with non-cleanup type", func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Error("expected panic, got nil") + } else if err, ok := r.(error); !ok || !errors.Is(err, ErrNoCleanupFunction) { + t.Errorf("expected panic with ErrNoCleanupFunction, got: %v", r) + } + }() + + // ensure we start with no stacks + PushWithCleanup(42) + }) +} diff --git a/internal/type-name.go b/internal/type-name.go new file mode 100644 index 0000000..4c4a316 --- /dev/null +++ b/internal/type-name.go @@ -0,0 +1,48 @@ +package internal + +import ( + "fmt" + "reflect" +) + +// TypeName returns the name of the type of the provided value. +// +// If the type cannot be determined (for example, if the value is nil), +// the name of the type parameter T is returned instead. +// +// Note that for unnamed types (for example, inline interfaces), "" +// is returned as the type name. +func TypeName[T any](v ...T) string { + var n string + var t T + + switch len(v) { + case 0: + n = fmt.Sprintf("%T", t) + + default: + n = fmt.Sprintf("%T", v[0]) + } + + // if we could not determine the type of the expected value using the + // zero value of the type, we can use a dummy function and reflect + // the type of the first argument to that function. + // + // Q: why not just use the dummy func technique every time? + // A: because it is more expensive than using the zero value, and + // using the zero value provides a more precise (package-qualified) + // type name, when possible + if n == "" { + fn := func(T) { /* NO-OP */ } + n = reflect.TypeOf(fn).In(0).Name() + + // call the function to ensure it is considered "covered" by tests + fn(t) + } + + if n == "" { + n = "" + } + + return n +} diff --git a/internal/type-name_test.go b/internal/type-name_test.go new file mode 100644 index 0000000..f35dfa5 --- /dev/null +++ b/internal/type-name_test.go @@ -0,0 +1,79 @@ +package internal_test + +import ( + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/internal" +) + +func TestTypeName(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "basic type", + Act: func() { + Expect(internal.TypeName[int]()).To(Equal("int")) + }, + }, + + {Scenario: "pointer to basic type", + Act: func() { + Expect(internal.TypeName[*int]()).To(Equal("*int")) + }, + }, + + {Scenario: "struct type", + Act: func() { + type MyStruct struct{} + Expect(internal.TypeName[MyStruct]()).To(Equal("internal_test.MyStruct")) + }, + }, + + {Scenario: "pointer to struct type", + Act: func() { + type MyStruct struct{} + Expect(internal.TypeName[*MyStruct]()).To(Equal("*internal_test.MyStruct")) + }, + }, + + {Scenario: "type based on basic type", + Act: func() { + type MyInt int + Expect(internal.TypeName[MyInt]()).To(Equal("internal_test.MyInt")) + }, + }, + + {Scenario: "pointer to type based on basic type", + Act: func() { + type MyInt int + Expect(internal.TypeName[*MyInt]()).To(Equal("*internal_test.MyInt")) + }, + }, + + {Scenario: "interface type", + Act: func() { + Expect(internal.TypeName[error]()).To(Equal("error")) + }, + }, + + {Scenario: "nil interface", + Act: func() { + var err error = nil + Expect(internal.TypeName(err)).To(Equal("error")) + }, + }, + + {Scenario: "anonymous struct", + Act: func() { + Expect(internal.TypeName[struct{ foo int }]()).To(Equal("struct { foo int }")) + }, + }, + + {Scenario: "anonymous interface", + Act: func() { + Expect(internal.TypeName[interface{ Foo() }]()).To(Equal("")) + }, + }, + }...)) +} diff --git a/match-boolean.go b/match-boolean.go new file mode 100644 index 0000000..8295072 --- /dev/null +++ b/match-boolean.go @@ -0,0 +1,25 @@ +package test + +import ( + "github.com/blugnu/test/matchers/bools" +) + +// BeFalse returns a matcher that will fail if the matched value is not false. +// +// # Supported Options +// +// opt.FailureReport(...) // a function returning a custom failure report +// // in the event that the test fails +func BeFalse() bools.BooleanMatcher { + return bools.BooleanMatcher{Expected: false} +} + +// BeTrue returns a matcher that will fail if the matched value is not true. +// +// # Supported Options +// +// opt.FailureReport(...) // a function returning a custom failure report +// // in the event that the test fails +func BeTrue() bools.BooleanMatcher { + return bools.BooleanMatcher{Expected: true} +} diff --git a/bool_test.go b/match-boolean_test.go similarity index 62% rename from bool_test.go rename to match-boolean_test.go index aad5eef..f2ee934 100644 --- a/bool_test.go +++ b/match-boolean_test.go @@ -4,6 +4,7 @@ import ( "testing" . "github.com/blugnu/test" + "github.com/blugnu/test/expect" "github.com/blugnu/test/opt" "github.com/blugnu/test/test" ) @@ -13,11 +14,11 @@ func TestBooleans(t *testing.T) { Run( HelperTests([]HelperScenario{ - {Scenario: "ExpectFalse when false", - Act: func() { ExpectFalse(false) }, + {Scenario: "expect.False when false", + Act: func() { expect.False(false) }, }, - {Scenario: "ExpectTrue when true", - Act: func() { ExpectTrue(true) }, + {Scenario: "expect.True when true", + Act: func() { expect.True(true) }, }, {Scenario: "BeFalse when false", Act: func() { Expect(false).To(BeFalse()) }, @@ -33,37 +34,30 @@ func TestBooleans(t *testing.T) { }, // supported options - {Scenario: "ExpectFalse with name", - Act: func() { ExpectFalse(true, "this will fail") }, + {Scenario: "expect.False with name", + Act: func() { expect.False(true, "this will fail") }, Assert: func(result *R) { - // Expect(result).To(HaveFailedWithReport( - // "this will fail:", - // " expected false, got true", - // )) result.Expect( "this will fail:", - " expected false, got true", + " expected false", ) }, }, - {Scenario: "ExpectFalse with custom failure report", + {Scenario: "expect.False with custom failure report", Act: func() { - ExpectFalse(true, opt.FailureReport(func(...any) []string { + expect.False(true, opt.FailureReport(func(...any) []string { return []string{"custom failure report"} })) }, Assert: func(result *R) { - // Expect(result).To(HaveFailedWithReport( - // "custom failure report", - // )) result.Expect( "custom failure report", ) }, }, - {Scenario: "ExpectFalse with name and custom failure report", + {Scenario: "expect.False with name and custom failure report", Act: func() { - ExpectFalse(true, "this will fail", opt.FailureReport(func(...any) []string { + expect.False(true, "this will fail", opt.FailureReport(func(...any) []string { return []string{"custom failure report"} })) }, @@ -74,18 +68,18 @@ func TestBooleans(t *testing.T) { ) }, }, - {Scenario: "ExpectTrue with name", - Act: func() { ExpectTrue(false, "this will fail") }, + {Scenario: "expect.True with name", + Act: func() { expect.True(false, "this will fail") }, Assert: func(result *R) { result.Expect( "this will fail:", - " expected true, got false", + " expected true", ) }, }, - {Scenario: "ExpectTrue with custom failure report", + {Scenario: "expect.True with custom failure report", Act: func() { - ExpectTrue(false, opt.FailureReport(func(...any) []string { + expect.True(false, opt.FailureReport(func(...any) []string { return []string{"custom failure report"} })) }, @@ -95,9 +89,9 @@ func TestBooleans(t *testing.T) { ) }, }, - {Scenario: "ExpectTrue with name and custom failure report", + {Scenario: "expect.True with name and custom failure report", Act: func() { - ExpectTrue(false, "this will fail", opt.FailureReport(func(...any) []string { + expect.True(false, "this will fail", opt.FailureReport(func(...any) []string { return []string{"custom failure report"} })) }, @@ -136,23 +130,9 @@ func TestBooleans(t *testing.T) { ) } -func ExampleExpectFalse() { - test.Example() - - ExpectFalse(true) - - // Output: - // expected false, got true -} - -func ExampleExpectTrue() { - test.Example() - - ExpectTrue(false) - - // Output: - // expected true, got false -} +// ================================================================= +// MARK: examples +// ================================================================= func ExampleBeFalse() { test.Example() @@ -160,7 +140,7 @@ func ExampleBeFalse() { Expect(true).To(BeFalse()) // Output: - // expected false, got true + // expected false } func ExampleBeTrue() { @@ -169,5 +149,5 @@ func ExampleBeTrue() { Expect(false).To(BeTrue()) // Output: - // expected true, got false + // expected true } diff --git a/bytes.go b/match-bytes.go similarity index 95% rename from bytes.go rename to match-bytes.go index dfe391d..acae7cb 100644 --- a/bytes.go +++ b/match-bytes.go @@ -1,6 +1,8 @@ package test -import "github.com/blugnu/test/matchers/bytes" +import ( + "github.com/blugnu/test/matchers/bytes" +) // EqualBytes returns a matcher that checks if a byte slice is equal to an // expected byte slice. diff --git a/bytes_test.go b/match-bytes_test.go similarity index 86% rename from bytes_test.go rename to match-bytes_test.go index 3515be2..7734ffa 100644 --- a/bytes_test.go +++ b/match-bytes_test.go @@ -8,7 +8,7 @@ import ( "github.com/blugnu/test/test" ) -func TestSliceOfBytes(t *testing.T) { +func TestEqualBytes(t *testing.T) { With(t) Run( @@ -43,6 +43,10 @@ func TestSliceOfBytes(t *testing.T) { ) } +// ================================================================= +// MARK: examples +// ================================================================= + func ExampleEqualBytes() { test.Example() diff --git a/context.go b/match-context.go similarity index 100% rename from context.go rename to match-context.go diff --git a/match-context_test.go b/match-context_test.go new file mode 100644 index 0000000..9a93cd7 --- /dev/null +++ b/match-context_test.go @@ -0,0 +1,107 @@ +package test_test + +import ( + "context" + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/opt" + "github.com/blugnu/test/test" +) + +func TestContext(t *testing.T) { + With(t) + + type key string + ctx := context.WithValue(context.Background(), key("key"), "value") + + Run( + Test("ContextKey", func() { + Run(HelperTests([]HelperScenario{ + {Scenario: "expected key present", + Act: func() { + Expect(ctx).To(HaveContextKey(key("key"))) + }, + }, + {Scenario: "expected key not present", + Act: func() { + Expect(ctx).To(HaveContextKey(key("other-key"))) + }, + Assert: func(result *R) { + result.Expect(test.Failed, opt.IgnoreReport(true)) + }, + }, + }...)) + })) + + Run(Test("ContextValue", func() { + Run(HelperTests([]HelperScenario{ + {Scenario: "expected value present", + Act: func() { + Expect(ctx).To(HaveContextValue(key("key"), "value")) + }, + }, + {Scenario: "expected value not present", + Act: func() { + Expect(ctx).To(HaveContextValue(key("other-key"), "value")) + }, + Assert: func(result *R) { + result.Expect(test.Failed, opt.IgnoreReport(true)) + }, + }, + {Scenario: "expected value present but different", + Act: func() { + Expect(ctx).To(HaveContextValue(key("key"), "other value")) + }, + Assert: func(result *R) { + result.Expect(test.Failed, opt.IgnoreReport(true)) + }, + }, + }...)) + }), + ) +} + +// ================================================================= +// MARK: examples +// ================================================================= + +func ExampleHaveContextKey() { + test.Example() + + type key int + ctx := context.WithValue(context.Background(), key(57), "varieties") + + // these tests will pass + Expect(ctx).To(HaveContextKey(key(57))) + Expect(ctx).ToNot(HaveContextKey(key(58))) + + // this test will fail + Expect(ctx).To(HaveContextKey(key(58))) + + // Output: + // expected key: test_test.key(58) + // key not present in context +} + +func ExampleHaveContextValue() { + // this is needed to make the example work; this would be usually + // be `With(t)` where `t` is the *testing.T + test.Example() + + type key int + ctx := context.WithValue(context.Background(), key(57), "varieties") + + // these tests will pass + Expect(ctx).To(HaveContextValue(key(57), "varieties")) + Expect(ctx).ToNot(HaveContextValue(key(56), "varieties")) + Expect(ctx).ToNot(HaveContextValue(key(57), "flavours")) + + // this test will fail + Expect(ctx).To(HaveContextValue(key(57), "flavours")) + + // Output: + // context value: test_test.key(57) + // expected: "flavours" + // got : "varieties" +} diff --git a/emptiness.go b/match-emptiness.go similarity index 81% rename from emptiness.go rename to match-emptiness.go index 6fec1e7..aa2538d 100644 --- a/emptiness.go +++ b/match-emptiness.go @@ -2,7 +2,6 @@ package test import ( "github.com/blugnu/test/matchers/emptiness" - "github.com/blugnu/test/matchers/length" ) // BeEmpty returns a matcher that checks if the value is empty. @@ -74,24 +73,3 @@ func BeEmpty() *emptiness.Matcher { func BeEmptyOrNil() *emptiness.Matcher { return &emptiness.Matcher{TreatNilAsEmpty: true} } - -// HaveLen returns a matcher that checks if the value has len() equal to n. -// -// The returned matcher is an `AnyMatcher` that may only be used with values -// of a type that is compatible with the built-in len() function. That is: -// -// - string -// - slice -// - array -// - channel -// - map -// -// A nil value of any of these types is considered to have a length of 0. -// -// If the value is of any other type, the test fails as an invalid test, -// with a message similar to: -// -// length.Matcher: requires a value that is a string, slice, channel, or map: got -func HaveLen(n int) *length.Matcher { - return &length.Matcher{Length: n} -} diff --git a/match-emptiness_test.go b/match-emptiness_test.go new file mode 100644 index 0000000..7876dd3 --- /dev/null +++ b/match-emptiness_test.go @@ -0,0 +1,101 @@ +package test_test + +import ( + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/test" +) + +func TestBeEmpty(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "empty slice is empty", + Act: func() { + Expect([]int{}).Should(BeEmpty()) + }, + }, + }...)) +} + +func TestBeEmptyOrNil(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "nil slice is empty", + Act: func() { + var nilSlice []int + Expect(nilSlice).Should(BeEmptyOrNil()) + }, + }, + }...)) +} + +// ================================================================= +// MARK: examples +// ================================================================= + +func ExampleBeEmpty() { + test.Example() + + // these tests all pass: + Expect([]int{}).Should(BeEmpty()) + Expect("").Should(BeEmpty()) + Expect(map[int]string{}).Should(BeEmpty()) + Expect([0]int{}).Should(BeEmpty()) + + // these tests all fail: + Expect([]int{1}).Should(BeEmpty()) + + var ch chan int + Expect(ch, "nil channel").Should(BeEmpty()) + + var ns []int + Expect(ns, "nil slice").Should(BeEmpty()) + + var nm map[int]struct{} + Expect(nm, "nil map").Should(BeEmpty()) + + // Output: + // expected: + // got : len() == 1 + // + // nil channel: + // expected: + // got : nil chan + // + // nil slice: + // expected: + // got : nil slice + // + // nil map: + // expected: + // got : nil map +} + +func ExampleBeEmptyOrNil() { + test.Example() + + // these tests all pass: + Expect([]int{}).Should(BeEmptyOrNil()) + Expect("").Should(BeEmptyOrNil()) + Expect(map[int]string{}).Should(BeEmptyOrNil()) + Expect([0]int{}).Should(BeEmptyOrNil()) + + var ch chan int + Expect(ch, "nil channel").Should(BeEmptyOrNil()) + + var ns []int + Expect(ns, "nil slice").Should(BeEmptyOrNil()) + + var nm map[int]struct{} + Expect(nm, "nil map").Should(BeEmptyOrNil()) + + // this test will fail: + Expect([]int{1}).Should(BeEmptyOrNil()) + + // Output: + // expected: + // got : len() == 1 +} diff --git a/equal.go b/match-equals.go similarity index 98% rename from equal.go rename to match-equals.go index 201611b..e03cabc 100644 --- a/equal.go +++ b/match-equals.go @@ -1,25 +1,21 @@ package test -import "github.com/blugnu/test/matchers/equal" +import ( + "github.com/blugnu/test/matchers/equal" +) -// Equal returns a matcher that checks if a value of type T is equal to some -// expected value of type T. The type T must be comparable. -// -// Equality is determined using ONE of the following, in order of preference: +// DeepEqual returns a matcher that checks if a value of type T is equal +// to some expected value of type T. // -// 1. any comparison function provided as an option -// 2. a T.Equal(T) bool method, if it exists -// 3. the == operator +// Equality is always evaluated using reflect.DeepEqual. i.e. the matcher does +// not support the use of a custom comparison function and will not use any +// Equal(T) method implemented by type T. // -// If specified, a custom comparison function must take two arguments of type -// T, returning a boolean indicating whether the values are considered equal. +// To use a custom comparison function or a T.Equal(T) method, use the Equal() +// matcher. // // # Supported Options // -// func(T, T) bool // a function to compare the values -// // (overriding the use of the == operator or -// // T.Equal(T) method, if it exists) -// // opt.QuotedString(bool) // determines whether string values in failure reports // // are quoted (default is true); the option has no // // effect on values that are not strings @@ -30,22 +26,28 @@ import "github.com/blugnu/test/matchers/equal" // // opt.FailureReport(func) // a function returning a custom failure report // // when the values are not equal -func Equal[T comparable](want T) equal.Matcher[T] { - return equal.Matcher[T]{Expected: want} +func DeepEqual[T any](want T) equal.DeepMatcher[T] { + return equal.DeepMatcher[T]{Expected: want} } -// DeepEqual returns a matcher that checks if a value of type T is equal -// to some expected value of type T. +// Equal returns a matcher that checks if a value of type T is equal to some +// expected value of type T. The type T must be comparable. // -// Equality is always evaluated using reflect.DeepEqual. i.e. the matcher does -// not support the use of a custom comparison function and will not use any -// Equal(T) method implemented by type T. +// Equality is determined using ONE of the following, in order of preference: // -// To use a custom comparison function or a T.Equal(T) method, use the Equal() -// matcher. +// 1. any comparison function provided as an option +// 2. a T.Equal(T) bool method, if it exists +// 3. the == operator +// +// If specified, a custom comparison function must take two arguments of type +// T, returning a boolean indicating whether the values are considered equal. // // # Supported Options // +// func(T, T) bool // a function to compare the values +// // (overriding the use of the == operator or +// // T.Equal(T) method, if it exists) +// // opt.QuotedString(bool) // determines whether string values in failure reports // // are quoted (default is true); the option has no // // effect on values that are not strings @@ -56,6 +58,6 @@ func Equal[T comparable](want T) equal.Matcher[T] { // // opt.FailureReport(func) // a function returning a custom failure report // // when the values are not equal -func DeepEqual[T any](want T) equal.DeepMatcher[T] { - return equal.DeepMatcher[T]{Expected: want} +func Equal[T comparable](want T) equal.Matcher[T] { + return equal.Matcher[T]{Expected: want} } diff --git a/equal_test.go b/match-equals_test.go similarity index 91% rename from equal_test.go rename to match-equals_test.go index b29f8f4..dce793a 100644 --- a/equal_test.go +++ b/match-equals_test.go @@ -7,19 +7,6 @@ import ( "github.com/blugnu/test/test" ) -func TestEqual(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "expected equal and was equal", - Act: func() { Expect(1).To(Equal(1)) }, - }, - {Scenario: "expected to not be equal and was not equal", - Act: func() { Expect(1).ToNot(Equal(2)) }, - }, - }...)) -} - func TestDeepEqual(t *testing.T) { With(t) @@ -37,6 +24,39 @@ func TestDeepEqual(t *testing.T) { }...)) } +func TestEqual(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "expected equal and was equal", + Act: func() { Expect(1).To(Equal(1)) }, + }, + {Scenario: "expected to not be equal and was not equal", + Act: func() { Expect(1).ToNot(Equal(2)) }, + }, + }...)) +} + +// ================================================================= +// MARK: examples +// ================================================================= + +func ExampleDeepEqual() { + test.Example() + + Expect([]byte{1, 2, 3}).To(DeepEqual([]byte{1, 2, 4})) + Expect([]uint8{1, 1, 2, 3, 5}).To(DeepEqual([]uint8{1, 2, 4, 8, 16})) + + // this will not compile because the types are not the same: + // Expect([]uint8{1, 1, 2, 3, 5}).To(DeepEqual([]int{1,1,2,3,5})) + + // Output: + // expected [1 2 4], got [1 2 3] + // + // expected: [1 2 4 8 16] + // got : [1 1 2 3 5] +} + func ExampleEqual() { test.Example() @@ -61,19 +81,3 @@ func ExampleEqual() { // expected: "the lord of the rings" // got : "the hobbit" } - -func ExampleDeepEqual() { - test.Example() - - Expect([]byte{1, 2, 3}).To(DeepEqual([]byte{1, 2, 4})) - Expect([]uint8{1, 1, 2, 3, 5}).To(DeepEqual([]uint8{1, 2, 4, 8, 16})) - - // this will not compile because the types are not the same: - // Expect([]uint8{1, 1, 2, 3, 5}).To(DeepEqual([]int{1,1,2,3,5})) - - // Output: - // expected [1 2 4], got [1 2 3] - // - // expected: [1 2 4 8 16] - // got : [1 1 2 3 5] -} diff --git a/match-errors-nil-and-panics.go b/match-errors-nil-and-panics.go new file mode 100644 index 0000000..41f2936 --- /dev/null +++ b/match-errors-nil-and-panics.go @@ -0,0 +1,350 @@ +package test + +import ( + "fmt" + "runtime" + + "github.com/blugnu/test/matchers/nilness" + "github.com/blugnu/test/matchers/panics" + "github.com/blugnu/test/matchers/typecheck" + "github.com/blugnu/test/opt" + "github.com/blugnu/test/test" +) + +// MARK: occurs + +// DidOccur is used to check whether an expected panic or error occurred. +// +// # Testing for Panics +// +// Use the Panic(r) function to create an expectation that a value r will +// be recovered from a panic. The call to DidOccur() must be deferred: +// +// defer Expect(Panic(r)).DidOccur(opts...) +// +// If the value r is an error the test will pass only if a panic occurs +// and an error is recovered from the panic that satisfies errors.Is(r). +// +// If the expected recovered value is not an error, the test passes if +// the recovered value is equal to the expected value, based on comparison +// using reflect.DeepEqual or a comparison function. +// +// # Supported Options +// +// func(a, b any) bool // a function to compare the values, overriding +// // the use of reflect.DeepEqual. +// +// # Testing for Errors +// +// To test for an error, use the error value as the expected value. +// The test will pass if the error is not nil: +// +// Expect(err).DidOccur() +// +// This is equivalent to: +// +// Expect(err).IsNotNil() +// +// NOTE: this approach to testing for errors is not recommended since +// the test will pass if any error occurred which may or may not be an +// expected error. This may be acceptable in very simple cases but +// it is usually better to test for a specific error using one of the +// other methods described below.: +// +// Expect(err).Is(expectedError) +// Expect(err).To(BeError(expectedErr)) +// expect.Error[E](err) +func (e expectation[T]) DidOccur(opts ...any) { + e.t.Helper() + + switch v := any(e.subject).(type) { + case panics.Expected: + match := &panics.MatchRecovered{R: recover()} + + if match.R != nil { + const bufsize = 65536 + stk := make([]byte, bufsize) + n := runtime.Stack(stk, false) + match.Stack = stk[:n-1] + } + + if !match.Match(v, opts...) { + e.fail(match, opts...) + } + + case error: + if v != nil { + return + } + + case nil: + e.err("expected error, got nil") + + default: + test.Invalid("test.DidOccur: may only be used with Panic() or error values") + } +} + +// DidNotOccur is used to ensure that a panic or error did not occur. +// +// # Testing for Panics +// +// Use the Panic() function to create an expectation for a Panic with +// an unspecified recovered value. The call to DidNotOccur() must be +// deferred: +// +// defer Expect(Panic()).DidNotOccur(opts...) +// +// The test will pass only if the function scope terminates without +// a panic having occurred. +// +// # Testing for Errors +// +// To test for an error, use the error value as the expected value. +// The test will pass if the error is nil: +// +// Expect(err).DidNotOccur() +// +// This is equivalent to: +// +// Expect(err).IsNil() +func (e expectation[T]) DidNotOccur(opts ...any) { + e.t.Helper() + + switch expected := any(e.subject).(type) { + case panics.Expected: + // for a "DidNotOccur" test, things are more complicated: + + // first let's grab any recoverable value and create a + // matcher which we'll use later... + matcher := &panics.MatchRecovered{R: recover()} + + // first, using DidNotOccur with Panic(nil) is invalid since it + // is likely to cause confusion + if expected.R == opt.NoPanicExpected(true) { + test.Invalid("DidNotOccur: may not be used with Panic(nil); did you mean NilPanic()?") + } + + // if we expect Panic(x) did NOT occur, but Panic(y) DID occur, + // then although the expectation was met, the UNexpected panic + // should still be reported as a test failure. + // + // so we use the MatchesPanic matcher to determine whether + // the recovered value matches the expected value... + recoveredExpectedValue := matcher.Match(expected, opts...) + + // if the recovered value matches the expected value, then + // the test has failed since this panic should not have occurred... + if recoveredExpectedValue && expected.R != nil { + // we add the ToNotMatch(true) option to indicate that the + // expectation was that the panic should not have occurred + e.fail(matcher, append(opts, opt.ToNotMatch(true))...) + return + } + + // but we're not done yet... + // + // the recovered value did not match the expected value, and if that + // recovered value is not nil, then we have an unexpected panic to report... + if matcher.R != nil { + // the existing matcher has already been used to test the recovered + // value against an expected value where-as we now need to report an + // unexpected panic (i.e. expected nil) + // + // so we create a new panic matcher, matching against an expected R:nil + // and use THAT to report the failure + matcher := &panics.MatchRecovered{R: matcher.R} + matcher.Match(panics.Expected{R: nil}) + e.fail(matcher, opts...) + } + + case error: + opts = append(opts, opt.FailureReport(func(opts ...any) []string { + return []string{ + "expected: ", + fmt.Sprintf("got : %T(%v)", expected, opt.ValueAsString(expected, opts...)), + } + })) + Expect(expected).IsNil(opts...) + + case nil: + return + + default: + test.Invalid("test.DidNotOccur: may only be used with Panic() or error values") + } +} + +// MARK: error + +// BeError returns a matcher that checks if the value is an error that satisfies +// [errors.As] for the type E. This test asserts the type of the error but does +// not return the error as that type. +// +// # Alternatives +// +// To perform further tests on a value that passes this check using a strongly +// typed value of type E, use: +// +// expect.Error[E](err) // returns an E and an indicator whether the test passed +// // or failed, without halting test execution on failure +// +// require.Error[E](err) // returns an E when the test passes; halts current test +// // execution when the test fails, avoiding the need +// // for a returned indicator +func BeError[E error]() typecheck.MatchError[E] { + return typecheck.MatchError[E]{} +} + +// MARK: nil + +// IsNil checks that the value of the expectation is nil. If the +// value is not nil, the test fails. If the value is nil, the test +// passes. +// +// If the value being tested does not support a nil value the test +// will fail and produce a report similar to: +// +// test.IsNil: values of type '' are not nilable +// +// # Supported Options +// +// opt.QuotedStrings(bool) // determines whether any non-nil string +// // values are quoted in any test failure +// // report. The default is false (string +// // values are quoted). +// // +// // If the value is not a string type this +// // option has no effect. +// +// opt.FailureReport(func) // a function that returns a custom test +// // failure report if the test fails. +// +// opt.OnFailure(string) // a string to output as the failure +// // report if the test fails. +func (e expectation[T]) IsNil(opts ...any) { + e.t.Helper() + e.Should(BeNil(), opts...) +} + +// IsNotNil checks that a specified value is not nil. If the value +// is not nil, the test fails. If the value is nil, the test passes. +// +// NOTE: If the value being tested does not support a nil value the +// test will pass. This is to allow for testing values that may be +// nilable or non-nilable. +// +// # Supported Options +// +// opt.FailureReport(func) // a function that returns a custom test +// // failure report if the test fails. +// +// opt.OnFailure(string) // a string to output as the failure +// // report if the test fails. +func (e expectation[T]) IsNotNil(opts ...any) { + e.t.Helper() + e.ShouldNot(BeNil(), opts...) +} + +// BeNil returns a matcher that checks if the value is nil. +// +// The returned matcher is an `AnyMatcher` that may only be used +// with the `Should()` method, or with `To()` where the subject +// is of formal type any. +// +// If used in a To() or Should() test with a subject that is not +// nilable, the test fails with a message similar to: +// +// test.BeNil: values of type '' are not nilable +// +// If used with ToNo() or ShouldNot() a non-nilable subject will +// pass the test. +// +// # Supported Options +// +// opt.QuotedStrings(bool) // determines whether any non-nil string +// // values are quoted in any test failure +// // report. The default is false (string +// // values are quoted). +// // +// // If the value is not a string type this +// // option has no effect. +// +// opt.FailureReport(func) // a function that returns a custom test +// // failure report if the test fails. +// +// opt.OnFailure(string) // a string to output as the failure +// // report if the test fails. +func BeNil() nilness.Matcher { + return nilness.Matcher{} +} + +// MARK: panics + +// NilPanic returns an expectation that a panic will occur that recovers +// a *runtime.PanicNilError. +// +// see: https://go.dev/blog/compat#expanded-godebug-support-in-go-121 +// +// For more information, refer to the # The Panic(nil) Special Case +// described in the [Panic] function documentation. +func NilPanic() panics.Expected { + return panics.Expected{R: &runtime.PanicNilError{}} +} + +// Panic returns an expectation subject that can be used to test whether a +// panic has occurred, optionally identifying a value that should match the +// value recovered from the expected panic. +// +// NOTE: At most ONE panic test should be expected per function. In addition, +// extreme care should be exercised when combining panic tests with other +// deferred recover() calls as these will also interfere with a panic test +// (or vice versa). +// +// # Usage +// +// - If called with no arguments, any panic will satisfy the expectation, +// regardless of the value recovered. +// +// - If called with a single argument, it will expect to recover a panic that +// recovers that value (unless the argument is nil; see The Panic(nil) +// Special Case, below) +// +// - If called with > 1 argument, the test will be failed as invalid. +// +// # The Panic(nil) Special Case +// +// Panic(nil) is a special case that is equivalent to "no panic expected". +// This is motivated by table-driven tests to avoid having to write conditional +// code to handle test cases where a panic is expected vs those where not. +// +// Treating Panic(nil) as "no panic expected" allows you to write: +// +// defer Expect(Panic(testcase.expectedPanic)).DidOccur() +// +// When testcase.expectedPanic is nil, this is equivalent to: +// +// defer Expect(Panic()).DidNotOccur() +// +// Should you need to test for an actual panic(nil), use: +// +// defer Expect(NilPanic()).DidOccur() +// +// Or, in a table-driven test, specify an expected recovery value of +// &runtime.PanicNilError{}. +func Panic(r ...any) panics.Expected { + switch len(r) { + case 0: + return panics.Expected{} + case 1: + if r[0] == nil { + return panics.Expected{R: opt.NoPanicExpected(true)} + } + return panics.Expected{R: r[0]} + } + + T().Helper() + test.Invalid(fmt.Sprintf("Panic: expected at most one argument, got %d", len(r))) + + return panics.Expected{} +} diff --git a/match-errors-nil-and-panics_test.go b/match-errors-nil-and-panics_test.go new file mode 100644 index 0000000..d20a9c9 --- /dev/null +++ b/match-errors-nil-and-panics_test.go @@ -0,0 +1,218 @@ +package test_test + +import ( + "errors" + "fmt" + "io/fs" + "runtime" + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/matchers/panics" + "github.com/blugnu/test/opt" + "github.com/blugnu/test/test" +) + +// MARK: errors + +type MyError struct { + msg string +} + +func (e MyError) Error() string { + return e.msg +} + +func TestBeError(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "error is of expected type", + Act: func() { + err := fmt.Errorf("an error occurred: %w", MyError{"invalid argument"}) + Expect(err).To(BeError[MyError]()) + }, + }, + + {Scenario: "error is not of expected type", + Act: func() { + err := errors.New("some other error") + Expect(err).To(BeError[MyError]()) + }, + Assert: func(result *R) { + result.Expect( + "expected: test_test.MyError", + "got : *errors.errorString", + ) + }, + }, + + {Scenario: "nil is not error", + Act: func() { Expect(error(nil)).To(BeError[error]()) }, + Assert: func(result *R) { + result.Expect( + "expected: error", + "got : nil", + ) + }, + }, + }...)) +} + +// MARK: nil + +func TestExpect_IsNil(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "nil", + Act: func() { var subject any; Expect(subject).IsNil() }, + }, + {Scenario: "nil error", + Act: func() { var err error; Expect(err).IsNil() }, + }, + + {Scenario: "non-nil error", + Act: func() { Expect(errors.New("error")).IsNil() }, + Assert: func(result *R) { + result.Expect("expected nil, got error: error") + }, + }, + {Scenario: "non-nilable subject", + Act: func() { Expect(0).IsNil() }, + Assert: func(result *R) { + result.ExpectInvalid( + "nilness.Matcher: values of type 'int' are not nilable", + ) + }, + }, + {Scenario: "with custom failure report", + Act: func() { + Expect(errors.New("not nil")).IsNil(opt.FailureReport(func(a ...any) []string { + return []string{"custom failure report"} + })) + }, + Assert: func(result *R) { + result.Expect("custom failure report") + }, + }, + }...)) +} + +func TestExpect_IsNotNil(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "non-nil error", + Act: func() { Expect(errors.New("error")).IsNotNil() }, + }, + {Scenario: "non-nilable subject", + Act: func() { Expect(0).IsNotNil() }, + }, + + {Scenario: "nil", + Act: func() { var subject any; Expect(subject).IsNotNil() }, + Assert: func(result *R) { + result.Expect("expected not nil") + }, + }, + {Scenario: "with custom failure report", + Act: func() { + var subject any + Expect(subject).IsNotNil(opt.FailureReport(func(a ...any) []string { + return []string{"custom failure report"} + })) + }, + Assert: func(result *R) { + result.Expect("custom failure report") + }, + }, + }...)) +} + +// MARK: panics + +func TestNilPanic(t *testing.T) { + With(t) + + result := NilPanic() + Expect(result).To(Equal(panics.Expected{R: &runtime.PanicNilError{}})) +} + +func TestPanic(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "with no args", + Act: func() { + result := Panic() + Expect(result).To(Equal(panics.Expected{})) + }, + }, + {Scenario: "with nil arg", + Act: func() { + result := Panic(nil) + Expect(result).To(Equal(panics.Expected{R: opt.NoPanicExpected(true)})) + }, + }, + {Scenario: "with non-nil arg", + Act: func() { + result := Panic("some string") + Expect(result).To(Equal(panics.Expected{R: "some string"})) + }, + }, + {Scenario: "with multiple args", + Act: func() { + result := Panic("one", "two") + Expect(result).To(Equal(panics.Expected{})) + }, + Assert: func(result *R) { + result.ExpectInvalid("Panic: expected at most one argument, got 2") + }, + }, + }...)) +} + +// ================================================================= +// MARK: examples +// ================================================================= + +func ExampleBeError() { + test.Example() + + err := fmt.Errorf("an error occurred: %w", MyError{"invalid argument"}) + + // this test will pass: + Expect(err).To(BeError[MyError]()) + Expect(err).ToNot(BeError[*fs.PathError]()) + + // these tests will fail: + Expect(error(nil)).To(BeError[error]()) + Expect(err).To(BeError[*fs.PathError]()) + + // Output: + // expected: error + // got : nil + // + // expected: *fs.PathError + // got : *fmt.wrapError +} + +func ExamplePanic() { + test.Example() + + // panic tests must be deferred so that the panic can be recovered; + // + // a stack trace is included by default but is disabled here to + // avoid breaking the example output + defer Expect(Panic("some string")).DidOccur(opt.NoStackTrace()) + + // cause a panic; note that the value is different to the value + // expected to be recovered, so the test will fail + panic("some other string") + + // Output: + // unexpected panic: + // expected : string("some string") + // recovered: string("some other string") +} diff --git a/expectationsWereMet.go b/match-expectations-were-met.go similarity index 100% rename from expectationsWereMet.go rename to match-expectations-were-met.go diff --git a/expectationsWereMet_test.go b/match-expectations-were-met_test.go similarity index 100% rename from expectationsWereMet_test.go rename to match-expectations-were-met_test.go diff --git a/match-length.go b/match-length.go new file mode 100644 index 0000000..7d30119 --- /dev/null +++ b/match-length.go @@ -0,0 +1,28 @@ +package test + +import ( + "github.com/blugnu/test/matchers/length" +) + +// HaveLen returns a matcher that checks if the value has len() equal to n. +// +// The returned matcher is an [AnyMatcher] that must be used with +// [expectation.Should] or [expectation.ShouldNot] expectations. The matcher +// supports values of any type that is compatible with the built-in len() +// function. That is: +// +// - array +// - channel +// - map +// - slice +// - string +// +// A nil value of any of these types is considered to have a length of 0. +// +// If the value is of any other type, the test fails as invalid, with a +// message similar to: +// +// `length.Matcher: requires a value that is a string, slice, channel, or map: got ` +func HaveLen(n int) *length.Matcher { + return &length.Matcher{Length: n} +} diff --git a/match-length_test.go b/match-length_test.go new file mode 100644 index 0000000..9cd2b16 --- /dev/null +++ b/match-length_test.go @@ -0,0 +1,72 @@ +package test_test + +import ( + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/test" +) + +func TestHaveLen(t *testing.T) { + With(t) + + // HaveLen returns a matcher from the matchers/length package which is + // thoroughly tested in that package; here we provide a simple test to + // verify that the HaveLen factory function is wired up correctly. + + Run(HelperTests([]HelperScenario{ + {Scenario: "slice with items", + Act: func() { + sut := []int{1, 2} + Expect(sut).Should(HaveLen(2)) + }, + }, + {Scenario: "nil slice", + Act: func() { + var sut []int + Expect(sut).Should(HaveLen(0)) + }, + }, + }...)) +} + +// ================================================================= +// MARK: examples +// ================================================================= + +func ExampleHaveLen() { + test.Example() + + // these tests all pass: + Expect([]int{}).Should(HaveLen(0)) + Expect(map[int]string{}).Should(HaveLen(0)) + Expect("").Should(HaveLen(0)) + Expect([0]int{}).Should(HaveLen(0)) + + var ch chan int + Expect(ch, "nil channel").Should(HaveLen(0)) + + var ns []int + Expect(ns, "nil slice").Should(HaveLen(0)) + + var nm map[int]struct{} + Expect(nm, "nil map").Should(HaveLen(0)) + + // these tests all fail: + Expect([]int{1, 2, 3}, "slice").Should(HaveLen(2)) + Expect(map[int]struct{}{1: {}, 2: {}, 3: {}}, "map").Should(HaveLen(2)) + Expect("abc", "string").Should(HaveLen(2)) + + // Output: + // slice: + // expected: len() == 2 + // got : len() == 3 + // + // map: + // expected: len() == 2 + // got : len() == 3 + // + // string: + // expected: len() == 2 + // got : len() == 3 +} diff --git a/map.go b/match-maps.go similarity index 100% rename from map.go rename to match-maps.go diff --git a/map_test.go b/match-maps_test.go similarity index 100% rename from map_test.go rename to match-maps_test.go diff --git a/ordered.go b/match-ordered.go similarity index 100% rename from ordered.go rename to match-ordered.go diff --git a/ordered_test.go b/match-ordered_test.go similarity index 100% rename from ordered_test.go rename to match-ordered_test.go diff --git a/slices.go b/match-slices.go similarity index 100% rename from slices.go rename to match-slices.go diff --git a/slices_test.go b/match-slices_test.go similarity index 100% rename from slices_test.go rename to match-slices_test.go diff --git a/string.go b/match-string.go similarity index 100% rename from string.go rename to match-string.go diff --git a/string_test.go b/match-string_test.go similarity index 100% rename from string_test.go rename to match-string_test.go diff --git a/match-typecheck.go b/match-typecheck.go new file mode 100644 index 0000000..490e300 --- /dev/null +++ b/match-typecheck.go @@ -0,0 +1,11 @@ +package test + +import "github.com/blugnu/test/matchers/typecheck" + +// BeOfType returns a matcher that checks if the value is of the expected type. +// +// To perform further tests on a value that passes this check using a strongly +// typed value of the expected type, use expect.Type or require.Type instead. +func BeOfType[T any]() typecheck.Matcher[T] { + return typecheck.Matcher[T]{} +} diff --git a/match-typecheck_test.go b/match-typecheck_test.go new file mode 100644 index 0000000..257999e --- /dev/null +++ b/match-typecheck_test.go @@ -0,0 +1,64 @@ +package test_test + +import ( + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/test" +) + +func TestShould_BeOfType(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "int is int", + Act: func() { Expect(42).Should(BeOfType[int]()) }, + }, + {Scenario: "int is not string", + Act: func() { Expect(42).Should(BeOfType[string]()) }, + Assert: func(result *R) { + result.Expect( + "expected type: string", + "got : int", + ) + }, + }, + }...)) +} + +func TestShouldNot_BeOfType(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "int is not string", + Act: func() { Expect(42).ShouldNot(BeOfType[string]()) }, + }, + {Scenario: "int is string", + Act: func() { Expect(42).ShouldNot(BeOfType[int]()) }, + Assert: func(result *R) { + result.Expect( + "should not be of type: int", + ) + }, + }, + }...)) +} + +// ================================================================= +// MARK: examples +// ================================================================= + +func ExampleBeOfType() { + test.Example() + + var i int = 42 + + Expect(i).Should(BeOfType[int]()) // passes + Expect(i).ShouldNot(BeOfType[string]()) // passes + + Expect(i).Should(BeOfType[bool]()) // fails + + // Output: + // expected type: bool + // got : int +} diff --git a/matchers/bools/bools_test.go b/matchers/bools/bools_test.go index c71815f..b778f21 100644 --- a/matchers/bools/bools_test.go +++ b/matchers/bools/bools_test.go @@ -18,7 +18,7 @@ func TestBooleans(t *testing.T) { Act: func() { Expect(true).To(BeFalse()) }, Assert: func(result *R) { result.Expect( - "expected false, got true", + "expected false", ) }, }, @@ -42,7 +42,7 @@ func TestBooleans(t *testing.T) { Act: func() { Expect(false).To(BeTrue()) }, Assert: func(result *R) { result.Expect( - "expected true, got false", + "expected true", ) }, }, diff --git a/matchers/bools/equal.go b/matchers/bools/equal.go index f7ee67f..fe1557f 100644 --- a/matchers/bools/equal.go +++ b/matchers/bools/equal.go @@ -6,7 +6,8 @@ import ( "github.com/blugnu/test/opt" ) -// implements the Matcher interface for testing expected bool values. +// BooleanMatcher implements the Matcher interface for testing expected +// bool values. type BooleanMatcher struct { Expected bool } @@ -20,5 +21,5 @@ func (m BooleanMatcher) OnTestFailure(got bool, opts ...any) string { if opt.IsSet(opts, opt.ToNotMatch(true)) { return fmt.Sprintf("did not expect %v", m.Expected) } - return fmt.Sprintf("expected %v, got %v", m.Expected, got) + return fmt.Sprintf("expected %v", m.Expected) } diff --git a/matchers/nilness/matcher.go b/matchers/nilness/matcher.go index 9a7b164..3956411 100644 --- a/matchers/nilness/matcher.go +++ b/matchers/nilness/matcher.go @@ -64,6 +64,9 @@ func (m Matcher) OnTestFailure(subject any, opts ...any) string { expect := "expected nil" if opt.IsSet(opts, opt.ToNotMatch(true)) { expect = "expected not nil" + if subject == nil { + return expect + } } switch got := subject.(type) { diff --git a/matchers/nilness/matcher_test.go b/matchers/nilness/matcher_test.go index 655d9f2..de114e4 100644 --- a/matchers/nilness/matcher_test.go +++ b/matchers/nilness/matcher_test.go @@ -119,7 +119,7 @@ func TestShouldNot_BeNil(t *testing.T) { {Scenario: "nil is not nil", Act: func() { Expect((any)(nil)).ShouldNot(BeNil()) }, Assert: func(result *R) { - result.Expect("expected not nil, got nil") + result.Expect("expected not nil") }, }, {Scenario: "int is not nil", diff --git a/matchers/panics/matchRecovered.go b/matchers/panics/match-recovered.go similarity index 100% rename from matchers/panics/matchRecovered.go rename to matchers/panics/match-recovered.go diff --git a/matchers/panics/matchRecovered_test.go b/matchers/panics/match-recovered_test.go similarity index 98% rename from matchers/panics/matchRecovered_test.go rename to matchers/panics/match-recovered_test.go index cd6e715..0c209c2 100644 --- a/matchers/panics/matchRecovered_test.go +++ b/matchers/panics/match-recovered_test.go @@ -6,6 +6,7 @@ import ( "testing" . "github.com/blugnu/test" + "github.com/blugnu/test/test" ) func TestPanic_DidOccur(t *testing.T) { @@ -29,7 +30,7 @@ func TestPanic_DidOccur(t *testing.T) { panic("panics with string") }, Assert: func(result *R) { - result.Expect(TestPanicked, "panics with string") + result.Expect(test.Panicked, "panics with string") }, }, {Scenario: "panicked with unexpected error", diff --git a/matchers/slices/appendToReport.go b/matchers/slices/append-to-report.go similarity index 100% rename from matchers/slices/appendToReport.go rename to matchers/slices/append-to-report.go diff --git a/matchers/slices/appendToReport_test.go b/matchers/slices/append-to-report_test.go similarity index 100% rename from matchers/slices/appendToReport_test.go rename to matchers/slices/append-to-report_test.go diff --git a/matchers/slices/containsItem.go b/matchers/slices/contains-item.go similarity index 96% rename from matchers/slices/containsItem.go rename to matchers/slices/contains-item.go index d6abfcd..9d87f60 100644 --- a/matchers/slices/containsItem.go +++ b/matchers/slices/contains-item.go @@ -8,7 +8,7 @@ import ( "github.com/blugnu/test/opt" ) -// ContainsItemsMatcher is a matcher for []T that will match the []T +// ContainsItemMatcher is a matcher for []T that will match the []T // if it contains at least one element that is equal to the expected // element. type ContainsItemMatcher[T any] struct { diff --git a/matchers/slices/containsItem_test.go b/matchers/slices/contains-item_test.go similarity index 100% rename from matchers/slices/containsItem_test.go rename to matchers/slices/contains-item_test.go diff --git a/matchers/slices/containsItems.go b/matchers/slices/contains-items.go similarity index 100% rename from matchers/slices/containsItems.go rename to matchers/slices/contains-items.go diff --git a/matchers/slices/containsItems_test.go b/matchers/slices/contains-items_test.go similarity index 100% rename from matchers/slices/containsItems_test.go rename to matchers/slices/contains-items_test.go diff --git a/matchers/slices/containsSlice.go b/matchers/slices/contains-slice.go similarity index 95% rename from matchers/slices/containsSlice.go rename to matchers/slices/contains-slice.go index 0c0eecb..76242fe 100644 --- a/matchers/slices/containsSlice.go +++ b/matchers/slices/contains-slice.go @@ -32,7 +32,7 @@ func (m ContainsSliceMatcher[T]) Match(got []T, opts ...any) bool { return slice[T](got).containsSlice(m.Expected, cmp) } -// TestFailure returns a report of the failure for the matcher. +// OnTestFailure returns a report of the failure for the matcher. func (m ContainsSliceMatcher[T]) OnTestFailure(got []T, opts ...any) []string { result := make([]string, 0, 2+len(got)+len(m.Expected)) cond := "containing slice" diff --git a/matchers/slices/containsSlice_test.go b/matchers/slices/contains-slice_test.go similarity index 100% rename from matchers/slices/containsSlice_test.go rename to matchers/slices/contains-slice_test.go diff --git a/matchers/slices/equalsSlice.go b/matchers/slices/equals-slice.go similarity index 96% rename from matchers/slices/equalsSlice.go rename to matchers/slices/equals-slice.go index e5e1a51..aff1ce1 100644 --- a/matchers/slices/equalsSlice.go +++ b/matchers/slices/equals-slice.go @@ -44,7 +44,7 @@ func (m EqualMatcher[T]) Match(got []T, opts ...any) bool { return slice[T](got).containsSlice(m.Expected, cmp) } -// TestFailure returns a report of the failure for the matcher. +// OnTestFailure returns a report of the failure for the matcher. func (m EqualMatcher[T]) OnTestFailure(got []T, opts ...any) []string { result := make([]string, 0, 2+len(got)+len(m.Expected)) cond := "equal to" diff --git a/matchers/slices/equalsSlice_test.go b/matchers/slices/equals-slice_test.go similarity index 100% rename from matchers/slices/equalsSlice_test.go rename to matchers/slices/equals-slice_test.go diff --git a/matchers/typecheck/match-error.go b/matchers/typecheck/match-error.go new file mode 100644 index 0000000..887312c --- /dev/null +++ b/matchers/typecheck/match-error.go @@ -0,0 +1,65 @@ +package typecheck + +import ( + "errors" + + "github.com/blugnu/test/internal" + "github.com/blugnu/test/opt" +) + +type MatchError[E error] struct { + Target E +} + +// Match checks that the value satisfies [errors.As] for an error of type +// E. If the value is not of the expected type and does not wrap an error +// of that type, the test fails. +// +// # Supported Options +// +// opt.QuotedStrings(bool) // determines whether any non-nil string +// // values are quoted in any test failure +// // report. The default is false (string +// // values are quoted). +// // +// // If the value is not a string type this +// // option has no effect. +// +// opt.FailureReport(func) // a function that returns a custom test +// // failure report if the test fails. +// +// opt.OnFailure(string) // a string to output as the failure +// // report if the test fails. +func (m MatchError[E]) Match(got error, opts ...any) bool { + return errors.As(got, &m.Target) +} + +// OnTestFailure returns a failure report indicating that the actual value +// was not of the expected type. +// +// If the ToNotMatch option is set, the report indicates that the value +// should not have been of the expected type. +func (m MatchError[E]) OnTestFailure(got any, opts ...any) []string { + expectedType := internal.TypeName[E]() + + if opt.IsSet(opts, opt.ToNotMatch(true)) { + return []string{"should not be of type: " + expectedType} + } + + // if got is nil we include this in the report as no error at all + // vs an error of the expected type is a significant difference + if got == nil { + return []string{ + "expected: " + expectedType, + "got : nil", + } + } + + // the type of got is not reported when not-nil since the desired + // error of type E may be in an expected wrapped error; the type of + // got is not particularly helpful + return []string{ + "expected: " + expectedType, + "got : " + internal.TypeName(got), + } +} diff --git a/matchers/typecheck/match-error_test.go b/matchers/typecheck/match-error_test.go new file mode 100644 index 0000000..f3385eb --- /dev/null +++ b/matchers/typecheck/match-error_test.go @@ -0,0 +1,82 @@ +package typecheck_test + +import ( + "errors" + "fmt" + "testing" + + . "github.com/blugnu/test" +) + +type MyError struct { + msg string +} + +func (e MyError) Error() string { + return e.msg +} + +func TestTo_BeError(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "error is of expected type", + Act: func() { + err := fmt.Errorf("an error occurred: %w", MyError{"invalid argument"}) + Expect(err).To(BeError[MyError]()) + }, + }, + + {Scenario: "error is not of expected type", + Act: func() { + err := errors.New("some other error") + Expect(err).To(BeError[MyError]()) + }, + Assert: func(result *R) { + result.Expect( + "expected: typecheck_test.MyError", + "got : *errors.errorString", + ) + }, + }, + + {Scenario: "nil is not error", + Act: func() { Expect(error(nil)).To(BeError[error]()) }, + Assert: func(result *R) { + result.Expect( + "expected: error", + "got : nil", + ) + }, + }, + }...)) +} + +func TestToNot_BeError(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "error is not of the expected type", + Act: func() { + err := errors.New("some other error") + Expect(err).ToNot(BeError[MyError]()) + }, + }, + + {Scenario: "error is of the expected type", + Act: func() { + err := fmt.Errorf("an error occurred: %w", MyError{"invalid argument"}) + Expect(err).ToNot(BeError[MyError]()) + }, + Assert: func(result *R) { + result.Expect( + "should not be of type: typecheck_test.MyError", + ) + }, + }, + + {Scenario: "nil is not of any error type", + Act: func() { Expect(error(nil)).ToNot(BeError[error]()) }, + }, + }...)) +} diff --git a/matchers/typecheck/matcher.go b/matchers/typecheck/matcher.go index 44b8048..8184b89 100644 --- a/matchers/typecheck/matcher.go +++ b/matchers/typecheck/matcher.go @@ -2,8 +2,8 @@ package typecheck import ( "fmt" - "reflect" + "github.com/blugnu/test/internal" "github.com/blugnu/test/opt" ) @@ -34,25 +34,7 @@ func (m Matcher[T]) Match(got any, opts ...any) bool { } func (m Matcher[T]) OnTestFailure(got any, opts ...any) []string { - var ( - expected T - expectedType = fmt.Sprintf("%T", expected) - ) - - // if we could not determine the type of the expected value using the - // zero value of the type, we can use a dummy function and reflect - // the type of the first argument to that function. - // - // Q: why not just use the dummy func technique every time? - // A: because it is more expensive than using the zero value, and - // using the zero value provides a more precise (package-qualified) - // type name - if expectedType == "" { - fn := func(T) { /* NO-OP */ } - fn(expected) // ensures that the dummy function is covered by tests - - expectedType = reflect.TypeOf(fn).In(0).Name() - } + expectedType := internal.TypeName[T]() if opt.IsSet(opts, opt.ToNotMatch(true)) { return []string{"should not be of type: " + expectedType} diff --git a/mockFn.go b/mock-fn.go similarity index 99% rename from mockFn.go rename to mock-fn.go index e9fe9bf..552c011 100644 --- a/mockFn.go +++ b/mock-fn.go @@ -293,7 +293,7 @@ func (mock *MockFn[A, R]) RecordCall(args ...A) (R, error) { return result, err } -// ExpectedResults returns an error if any expectations were not met; otherwise nil. +// ExpectationsWereMet returns an error if any expectations were not met; otherwise nil. // // This method is typically called at the end of a test to ensure that all expected // calls were made. diff --git a/mockFn_test.go b/mock-fn_test.go similarity index 100% rename from mockFn_test.go rename to mock-fn_test.go diff --git a/nilness.go b/nilness.go deleted file mode 100644 index d2f2572..0000000 --- a/nilness.go +++ /dev/null @@ -1,84 +0,0 @@ -package test - -import "github.com/blugnu/test/matchers/nilness" - -// IsNil checks that the value of the expectation is nil. If the -// value is not nil, the test fails. If the value is nil, the test -// passes. -// -// If the value being tested does not support a nil value the test -// will fail and produce a report similar to: -// -// test.IsNil: values of type '' are not nilable -// -// # Supported Options -// -// opt.QuotedStrings(bool) // determines whether any non-nil string -// // values are quoted in any test failure -// // report. The default is false (string -// // values are quoted). -// // -// // If the value is not a string type this -// // option has no effect. -// -// opt.FailureReport(func) // a function that returns a custom test -// // failure report if the test fails. -// -// opt.OnFailure(string) // a string to output as the failure -// // report if the test fails. -func (e expectation[T]) IsNil(opts ...any) { - e.t.Helper() - e.Should(BeNil(), opts...) -} - -// IsNotNil checks that a specified value is not nil. If the value -// is not nil, the test fails. If the value is nil, the test passes. -// -// NOTE: If the value being tested does not support a nil value the -// test will pass. This is to allow for testing values that may be -// nilable or non-nilable. -// -// # Supported Options -// -// opt.FailureReport(func) // a function that returns a custom test -// // failure report if the test fails. -// -// opt.OnFailure(string) // a string to output as the failure -// // report if the test fails. -func (e expectation[T]) IsNotNil(opts ...any) { - e.t.Helper() - e.ShouldNot(BeNil(), opts...) -} - -// BeNil returns a matcher that checks if the value is nil. -// -// The returned matcher is an `AnyMatcher` that may only be used -// with the `Should()` method, or with `To()` where the subject -// is of formal type any. -// -// If used in a To() or Should() test with a subject that is not -// nilable, the test fails with a message similar to: -// -// test.BeNil: values of type '' are not nilable -// -// If used with ToNo() or ShouldNot() a non-nilable subject will -// pass the test. -// -// # Supported Options -// -// opt.QuotedStrings(bool) // determines whether any non-nil string -// // values are quoted in any test failure -// // report. The default is false (string -// // values are quoted). -// // -// // If the value is not a string type this -// // option has no effect. -// -// opt.FailureReport(func) // a function that returns a custom test -// // failure report if the test fails. -// -// opt.OnFailure(string) // a string to output as the failure -// // report if the test fails. -func BeNil() nilness.Matcher { - return nilness.Matcher{} -} diff --git a/nilness_test.go b/nilness_test.go deleted file mode 100644 index c57a4a3..0000000 --- a/nilness_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package test_test - -import ( - "errors" - "testing" - - . "github.com/blugnu/test" - "github.com/blugnu/test/opt" -) - -func TestExpect_IsNil(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "nil", - Act: func() { var subject any; Expect(subject).IsNil() }, - }, - {Scenario: "nil error", - Act: func() { var err error; Expect(err).IsNil() }, - }, - - {Scenario: "non-nil error", - Act: func() { Expect(errors.New("error")).IsNil() }, - Assert: func(result *R) { - result.Expect("expected nil, got error: error") - }, - }, - {Scenario: "non-nilable subject", - Act: func() { Expect(0).IsNil() }, - Assert: func(result *R) { - result.ExpectInvalid( - "nilness.Matcher: values of type 'int' are not nilable", - ) - }, - }, - {Scenario: "with custom failure report", - Act: func() { - Expect(errors.New("not nil")).IsNil(opt.FailureReport(func(a ...any) []string { - return []string{"custom failure report"} - })) - }, - Assert: func(result *R) { - result.Expect("custom failure report") - }, - }, - }...)) -} - -func TestExpect_IsNotNil(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "non-nil error", - Act: func() { Expect(errors.New("error")).IsNotNil() }, - }, - {Scenario: "non-nilable subject", - Act: func() { Expect(0).IsNotNil() }, - }, - - {Scenario: "nil", - Act: func() { var subject any; Expect(subject).IsNotNil() }, - Assert: func(result *R) { - result.Expect("expected not nil, got nil") - }, - }, - {Scenario: "with custom failure report", - Act: func() { - var subject any - Expect(subject).IsNotNil(opt.FailureReport(func(a ...any) []string { - return []string{"custom failure report"} - })) - }, - Assert: func(result *R) { - result.Expect("custom failure report") - }, - }, - }...)) -} diff --git a/opt/anyAreSet.go b/opt/any-are-set.go similarity index 100% rename from opt/anyAreSet.go rename to opt/any-are-set.go diff --git a/opt/anyAreSet_test.go b/opt/any-are-set_test.go similarity index 100% rename from opt/anyAreSet_test.go rename to opt/any-are-set_test.go diff --git a/opt/failureReport.go b/opt/failure-report.go similarity index 91% rename from opt/failureReport.go rename to opt/failure-report.go index b76d406..f8fb4a6 100644 --- a/opt/failureReport.go +++ b/opt/failure-report.go @@ -12,7 +12,7 @@ package opt // })) type FailureReport func(...any) []string -// TestFailure implements the TestFailure(...any) []string interface, +// OnTestFailure implements the TestFailure(...any) []string interface, // calling the function with the provided options. func (f FailureReport) OnTestFailure(opts ...any) []string { return f(opts...) diff --git a/opt/failureReport_test.go b/opt/failure-report_test.go similarity index 100% rename from opt/failureReport_test.go rename to opt/failure-report_test.go diff --git a/opt/get.go b/opt/get.go index 5fb458c..97afcb3 100644 --- a/opt/get.go +++ b/opt/get.go @@ -1,7 +1,7 @@ package opt -// Get extracts an option of a given type from a variadic list of options. -// Only the first value of type T is significant; any additional T values +// Get extracts an option of a given type from a slice of options. Only the +// first value of type T in the slice is significant; any additional T values // are ignored. // // The function returns the option value and true if an option of the @@ -14,5 +14,6 @@ func Get[T any](opts []any) (T, bool) { } } - return *new(T), false + var zero T + return zero, false } diff --git a/opt/isSet.go b/opt/is-set.go similarity index 100% rename from opt/isSet.go rename to opt/is-set.go diff --git a/opt/isSet_test.go b/opt/is-set_test.go similarity index 100% rename from opt/isSet_test.go rename to opt/is-set_test.go diff --git a/opt/opts.go b/opt/opts.go index 28130f7..f90911d 100644 --- a/opt/opts.go +++ b/opt/opts.go @@ -31,8 +31,8 @@ type IgnoreReport bool // see also: Require() type IsRequired bool -// NoPanic is an internal option used as a sentinel recover value by the panic -// testing mechanism to signal that a panic is NOT expected to occur +// NoPanicExpected is an internal option used as a sentinel recover value by +// the panic testing mechanism to signal that a panic is NOT expected to occur type NoPanicExpected bool // PrefixInlineWithFirstItem may be used to indicate that the first item diff --git a/opt/opts_test.go b/opt/opts_test.go index b264eab..d11488c 100644 --- a/opt/opts_test.go +++ b/opt/opts_test.go @@ -4,6 +4,7 @@ import ( "testing" . "github.com/blugnu/test" + "github.com/blugnu/test/expect" "github.com/blugnu/test/opt" ) @@ -12,7 +13,7 @@ func TestAnyOrder(t *testing.T) { result := opt.AnyOrder() - if value, ok := ExpectType[opt.ExactOrder](result); ok { + if value, ok := expect.Type[opt.ExactOrder](result); ok { Expect(value).To(Equal(opt.ExactOrder(false))) } } @@ -22,7 +23,7 @@ func TestRequired(t *testing.T) { result := opt.Required() - if value, ok := ExpectType[opt.IsRequired](result); ok { + if value, ok := expect.Type[opt.IsRequired](result); ok { Expect(value).To(Equal(opt.IsRequired(true))) } } @@ -32,7 +33,7 @@ func TestNoStackTrace(t *testing.T) { result := opt.NoStackTrace() - if value, ok := ExpectType[opt.StackTrace](result); ok { + if value, ok := expect.Type[opt.StackTrace](result); ok { Expect(value).To(Equal(opt.StackTrace(false))) } } @@ -42,7 +43,7 @@ func TestUnquotedStrings(t *testing.T) { result := opt.UnquotedStrings() - if value, ok := ExpectType[opt.QuotedStrings](result); ok { + if value, ok := expect.Type[opt.QuotedStrings](result); ok { Expect(value).To(Equal(opt.QuotedStrings(false))) } } diff --git a/opt/valueAsString.go b/opt/value-as-string.go similarity index 80% rename from opt/valueAsString.go rename to opt/value-as-string.go index a292e64..42efff3 100644 --- a/opt/valueAsString.go +++ b/opt/value-as-string.go @@ -5,9 +5,10 @@ import ( "reflect" ) -// FormatString formats a string according to the provided options. +// ValueAsString converts a value to string representation, according to the +// provided options. // -// This function should be used in TestFailure methods to format expected and +// This function should be used in test failure methods to format expected and // actual values consistently with other matcher failure reports. // // Supported options: diff --git a/opt/valueAsString_test.go b/opt/value-as-string_test.go similarity index 100% rename from opt/valueAsString_test.go rename to opt/value-as-string_test.go diff --git a/panic.go b/panic.go deleted file mode 100644 index 1f35177..0000000 --- a/panic.go +++ /dev/null @@ -1,106 +0,0 @@ -package test - -import ( - "fmt" - "runtime" - - "github.com/blugnu/test/matchers/panics" - "github.com/blugnu/test/opt" - "github.com/blugnu/test/test" -) - -// Panic returns an expectation subject that can be used to test whether a -// panic has occurred, optionally identifying a value that should match the -// value recovered from the expected panic. -// -// NOTE: At most ONE panic test should be expected per function. In addition, -// extreme care should be exercised when combining panic tests with other -// deferred recover() calls as these will also interfere with a panic test -// (or vice versa). -// -// # Usage -// -// - If called with no arguments, any panic will satisfy the expectation, -// regardless of the value recovered. -// -// - If called with a single argument, it will expect to recover a panic that -// recovers that value (unless the argument is nil; see The Panic(nil) -// Special Case, below) -// -// - If called with > 1 argument, the test will be failed as invalid. -// -// # The Panic(nil) Special Case -// -// Panic(nil) is a special case that is equivalent to "no panic expected". -// This is motivated by table-driven tests to avoid having to write conditional -// code to handle test cases where a panic is expected vs those where not. -// -// Treating Panic(nil) as "no panic expected" allows you to write: -// -// defer Expect(Panic(testcase.expectedPanic)).DidOccur() -// -// When testcase.expectedPanic is nil, this is equivalent to: -// -// defer Expect(Panic()).DidNotOccur() -// -// Should you need to test for an actual panic(nil), use: -// -// defer Expect(NilPanic()).DidOccur() -// -// Or, in a table-driven test, specify an expected recovery value of -// &runtime.PanicNilError{}. -func Panic(r ...any) panics.Expected { - switch len(r) { - case 0: - return panics.Expected{} - case 1: - if r[0] == nil { - return panics.Expected{R: opt.NoPanicExpected(true)} - } - return panics.Expected{R: r[0]} - } - - T().Helper() - test.Invalid(fmt.Sprintf("Panic: expected at most one argument, got %d", len(r))) - - return panics.Expected{} -} - -// NilPanic returns an expectation that a panic will occur that recovers -// a *runtime.PanicNilError. -// -// Panic(nil) is syntactic sugar for "no panic expected", to simplify -// table-drive tests where each test case may or may not expect a panic, -// enabling the use of a single Expect() call. -// -// i.e. instead of writing: -// -// if testcase.expectedPanic == nil { -// defer Expect(Panic()).DidNotOccur() -// } else { -// defer Expect(Panic(testcase.expectedPanic)).DidOccur() -// } -// -// you can write: -// -// defer Expect(Panic(testcase.expectedPanic)).DidOccur() -// -// When testcase.expectedPanic is nil, this is equivalent to: -// -// defer Expect(Panic()).DidNotOccur() -// -// Without having to write conditional code to handle the different -// expectations. -// -// # Testing for a nil panic -// -// In the unlikely event that you specifically need to test for a -// panic(nil), you can use the NilPanic() function, which will -// create an expectation for a panic that recovers a *runtime.PanicNilError. -// -// see: https://go.dev/blog/compat#expanded-godebug-support-in-go-121 -func NilPanic() panics.Expected { - // This is a convenience function to create an expectation for a nil panic. - // It is equivalent to Panic(nil). - return panics.Expected{R: &runtime.PanicNilError{}} -} diff --git a/panic_test.go b/panic_test.go deleted file mode 100644 index f81a7d8..0000000 --- a/panic_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package test_test - -import ( - "runtime" - "testing" - - . "github.com/blugnu/test" - "github.com/blugnu/test/matchers/panics" - "github.com/blugnu/test/opt" - "github.com/blugnu/test/test" -) - -func TestNilPanic(t *testing.T) { - With(t) - - result := NilPanic() - Expect(result).To(Equal(panics.Expected{R: &runtime.PanicNilError{}})) -} - -func TestPanic(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "with no args", - Act: func() { - result := Panic() - Expect(result).To(Equal(panics.Expected{})) - }, - }, - {Scenario: "with nil arg", - Act: func() { - result := Panic(nil) - Expect(result).To(Equal(panics.Expected{R: opt.NoPanicExpected(true)})) - }, - }, - {Scenario: "with non-nil arg", - Act: func() { - result := Panic("some string") - Expect(result).To(Equal(panics.Expected{R: "some string"})) - }, - }, - {Scenario: "with multiple args", - Act: func() { - result := Panic("one", "two") - Expect(result).To(Equal(panics.Expected{})) - }, - Assert: func(result *R) { - result.ExpectInvalid("Panic: expected at most one argument, got 2") - }, - }, - }...)) -} - -func ExamplePanic() { - test.Example() - - // a stack trace is included by default, but is disabled for this - // example to avoid breaking the example output - defer Expect(Panic("some string")).DidOccur(opt.NoStackTrace()) - - panic("some other string") - - // Output: - // unexpected panic: - // expected : string("some string") - // recovered: string("some other string") -} diff --git a/parallel.go b/parallel.go index 86c94d4..a865603 100644 --- a/parallel.go +++ b/parallel.go @@ -1,37 +1,14 @@ package test import ( - "reflect" - "testing" - "github.com/blugnu/test/internal/testframe" "github.com/blugnu/test/test" ) -// isParallel checks if the given TestingT is running in parallel. -// -// The underlying type of TestingT must be a *testing.T; any other -// implementation will return false. -// -// For *testing.T values, reflection is used to examine the internal -// state to determine if it is running in parallel. -func isParallel(t TestingT) bool { - tf := T() - if _, ok := tf.(*testing.T); !ok { - // tests cannot be parallel if the TestingT is not a *testing.T - return false - } - - c := reflect.Indirect(reflect.ValueOf(t)).FieldByName("common") - ip := reflect.Indirect(c).FieldByName("isParallel") - - return ip.Bool() -} - // IsParallel returns true if the current test is running in parallel or is a // sub-test of a parallel test. func IsParallel() bool { - return isParallel(T()) + return testframe.IsParallel() } // Parallel establishes a new test frame scheduled for parallel execution. @@ -58,17 +35,17 @@ func IsParallel() bool { // Parallel must not be called from a test that is already parallel or with // a nil argument; in both cases the test will be failed as invalid. func Parallel(t TestingT) { - if t == nil { - if t, ok := testframe.Peek[TestingT](); ok { - t.Helper() - } - test.Invalid("Parallel() cannot be called with nil") + // mark t as a helper if possible + if t, ok := testframe.Peek[TestingT](); ok { + t.Helper() } - t.Helper() + switch { + case t == nil: + test.Invalid("Parallel() cannot be called with nil") - if isParallel(t) { - test.Invalid("Parallel() cannot be called from a parallel test") + case testframe.IsParallel(): + test.Invalid("Parallel() must not be called from a parallel test") } With(t) diff --git a/parallel_test.go b/parallel_test.go index c7b8fb8..6968fc6 100644 --- a/parallel_test.go +++ b/parallel_test.go @@ -137,7 +137,7 @@ func TestParallelTests(t *testing.T) { Parallel(T()) }) result.ExpectInvalid( - "Parallel() cannot be called from a parallel test", + "Parallel() must not be called from a parallel test", ) })) diff --git a/pkgdocs.go b/pkgdocs.go new file mode 100644 index 0000000..e71c522 --- /dev/null +++ b/pkgdocs.go @@ -0,0 +1,50 @@ +package test + +// AnyMatcher is the interface implemented by matchers that can test +// any type of value. It is used to apply matchers to expectations +// that are not type-specific. +// +// It is preferable to use the Matcher[T] interface for type-safe +// expectations; AnyMatcher is provided for situations where the +// compatible types for a matcher cannot be enforced at compile-time. +// +// When implementing an AnyMatcher, it is important to ensure that +// the matcher fails a test if it is not used correctly, i.e. if the +// matcher is not compatible with the type of the value being tested. +// +// An AnyMatcher must be used with the Expect().Should() matching +// function; they may also be used with Expect(got).To() where the got +// value is of type `any`, though this is not recommended. +// +// NOTE: This interface is not referenced by the test package; the +// declaration is provided for documentation purposes only. +type AnyMatcher interface { + Match(got any, opts ...any) bool +} + +// Matcher is a generic interface that describes the method set of +// a type-safe matcher for testing values of a specific type, T. +// +// A type-safe matcher is not necessarily generic. A matcher +// that implements Match(got X, opts ...any) bool, where X is a formal, +// literal type (i.e. not generic) is also a type-safe matcher. +// +// `Matcher[T]` describes the general form of a type-safe matcher. +// +// # Generic Matchers +// +// A type-safe matcher that implements support for a specific type is +// limited to only testing values of that explicit type. +// +// A generic matcher uses type constraints to specify a set of +// compatible types for the matcher, enabling that matcher to be used +// with a variety of types. +// +// For example, the equals.Matcher[T comparable] may be used to test, +// values of any type that is comparable (supports comparison using ==). +// +// NOTE: This interface is not referenced by the test package; the +// declaration is provided for documentation purposes only. +type Matcher[T any] interface { + Match(got T, opts ...any) bool +} diff --git a/require/bool.go b/require/bool.go new file mode 100644 index 0000000..3211f59 --- /dev/null +++ b/require/bool.go @@ -0,0 +1,47 @@ +package require + +import "github.com/blugnu/test" + +// False fails a test if a specified bool is not false. An optional +// name (string) may be specified to be included in the test report in the +// event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Require(got).To(Equal(false)) +// Require(got).To(BeFalse()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func False[T ~bool](got T, opts ...any) { + test.T().Helper() + test.Require(bool(got), opts...).To(test.BeFalse(), opts...) +} + +// True fails and halts execution of the current test if a specified bool +// is not true. An optional name (string) may be specified to be included +// in the test report in the event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Expect(got).To(Equal(true), opt.Required()) +// Expect(got).To(BeTrue(), opt.Required()) +// Require(got).To(Equal(true)) +// Require(got).To(BeTrue()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func True[T ~bool](got T, opts ...any) { + test.T().Helper() + test.Require(bool(got), opts...).To(test.BeTrue(), opts...) +} diff --git a/require/bool_test.go b/require/bool_test.go new file mode 100644 index 0000000..bd63f74 --- /dev/null +++ b/require/bool_test.go @@ -0,0 +1,35 @@ +package require_test + +import ( + "github.com/blugnu/test/expect" + "github.com/blugnu/test/require" + "github.com/blugnu/test/test" +) + +func ExampleFalse() { + test.Example() + + a := 1 + + // only one test failure is reported by these tests + + require.False(a == 1) // will fail and stop the test here + expect.False(a+1-1 == 1) // this would also fail but is not evaluated + + // Output: + // expected false +} + +func ExampleTrue() { + test.Example() + + a := 1 + + // only one test failure is reported by these tests + + require.True(a == 0) // will fail and stop the test here + expect.True(a < 0) // this would also fail but is not evaluated + + // Output: + // expected true +} diff --git a/require/error.go b/require/error.go new file mode 100644 index 0000000..131009e --- /dev/null +++ b/require/error.go @@ -0,0 +1,26 @@ +package require + +import ( + "github.com/blugnu/test/expect" + "github.com/blugnu/test/opt" + + "github.com/blugnu/test" +) + +// Error[E] tests that an error satisfies [errors.As] for a specified +// error type E. If the test passes, the value is returned as that type. +// If the test fails, the test fails immediately without evaluating any +// further expectations. +// +// If a test does not use the returned value, consider using the [BeError] +// matcher instead, to avoid lint warnings about unused return values. +// +// If a failure to match the expected error type is not a fatal error for the +// test, consider using [expect.Error] instead. +func Error[E error](got error, opts ...any) E { + test.T().Helper() + + target, _ := expect.Error[E](got, append(opts, opt.Required())...) + + return target +} diff --git a/require/error_test.go b/require/error_test.go new file mode 100644 index 0000000..96745a2 --- /dev/null +++ b/require/error_test.go @@ -0,0 +1,73 @@ +package require_test + +import ( + "errors" + "fmt" + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/require" +) + +type MyError struct { + msg string +} + +func (e MyError) Error() string { + return e.msg +} + +func TestError(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "error is of expected type", + Act: func() { + // arrange + err := fmt.Errorf("an error occurred: %w", MyError{"invalid argument"}) + + // act + e := require.Error[MyError](err) + + // assert + Expect(e).To(Equal(MyError{"invalid argument"})) + Error("subsequent failures should appear in report") + }, + Assert: func(result *R) { + result.Expect( + "subsequent failures should appear in report", + ) + }, + }, + + {Scenario: "error is not of expected type", + Act: func() { + // arrange + err := errors.New("some other error") + + // act + _ = require.Error[MyError](err) + Error("subsequent failures should not appear in report") + }, + Assert: func(result *R) { + result.Expect( + "expected error: require_test.MyError", + ) + }, + }, + + {Scenario: "nil should not be identified as any error", + Act: func() { + // act + _ = require.Error[error](error(nil)) + Error("subsequent failures should not appear in report") + }, + Assert: func(result *R) { + result.Expect( + "expected error: error", + "got : nil", + ) + }, + }, + }...)) +} diff --git a/require/nil.go b/require/nil.go new file mode 100644 index 0000000..122434d --- /dev/null +++ b/require/nil.go @@ -0,0 +1,45 @@ +package require + +import "github.com/blugnu/test" + +// Nil fails and halts the current test if a specified value is not nil. +// An optional name (string) may be specified to be included in the test +// report in the event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Require(got).IsNil() +// Require(got).Should(BeNil()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func Nil(got any, opts ...any) { + test.T().Helper() + test.Require(got, opts...).Should(test.BeNil(), opts...) +} + +// NotNil fails and halts the current test if a specified value is nil. +// An optional name (string) may be specified to be included in the test +// report in the event of failure. +// +// This test is a convenience for these equivalent alternatives: +// +// Require(got).IsNot(nil) +// Require(got).ShouldNot(BeNil()) +// +// # Supported Options +// +// string // a name for the value, for use in any test +// // failure report +// +// opt.FailureReport(func) // a function returning a custom failure report +// // in the event that the test fails +func NotNil(got any, opts ...any) { + test.T().Helper() + test.Require(got, opts...).ShouldNot(test.BeNil(), opts...) +} diff --git a/require/nil_test.go b/require/nil_test.go new file mode 100644 index 0000000..ed0bebd --- /dev/null +++ b/require/nil_test.go @@ -0,0 +1,32 @@ +package require_test + +import ( + "errors" + + "github.com/blugnu/test/require" + "github.com/blugnu/test/test" +) + +func ExampleNil() { + test.Example() + + err := errors.New("an error") + require.Nil(nil) // will pass + require.Nil(err) // will fail + require.Nil(42) // will not be reached + + // Output: + // expected nil, got error: an error +} + +func ExampleNotNil() { + test.Example() + + err := errors.New("an error") + require.NotNil(err) // will pass + require.NotNil(nil) // will fail + require.NotNil(42) // will not be reached + + // Output: + // expected not nil +} diff --git a/require/pkgdoc.go b/require/pkgdoc.go new file mode 100644 index 0000000..c16a8d5 --- /dev/null +++ b/require/pkgdoc.go @@ -0,0 +1,6 @@ +// Package require provides conclusory type assertion functions that operate on a +// subject value. If the assertion fails, the test is failed and halted immediately; +// no further expectations in the test will be evaluated. +package require + +// this file is provided for package documentation purposes and is intentionally left blank diff --git a/require/type.go b/require/type.go new file mode 100644 index 0000000..76e18a8 --- /dev/null +++ b/require/type.go @@ -0,0 +1,21 @@ +package require + +import ( + "github.com/blugnu/test" + "github.com/blugnu/test/expect" + "github.com/blugnu/test/opt" +) + +// Type tests that a value is of an expected type. If the test passes, +// the value is returned as that type otherwise the test fails immediately +// without evaluating any further expectations. +// +// If a test does not use the returned value, consider using the [BeOfType] +// matcher instead, to avoid lint warnings about unused return values. +func Type[T any](got any, opts ...any) T { + test.T().Helper() + + z, _ := expect.Type[T](got, append(opts, opt.Required())...) + + return z +} diff --git a/require/type_test.go b/require/type_test.go new file mode 100644 index 0000000..4c632d6 --- /dev/null +++ b/require/type_test.go @@ -0,0 +1,72 @@ +package require_test + +import ( + "fmt" + "testing" + + . "github.com/blugnu/test" + + "github.com/blugnu/test/require" + "github.com/blugnu/test/test" +) + +func TestExpectType(t *testing.T) { + With(t) + + Run(HelperTests([]HelperScenario{ + {Scenario: "subsequent tests are evaluated when test passes", + Act: func() { + result := require.Type[int](1) + Expect(result).To(Equal(1)) + + Expect(false).To(BeTrue()) + }, + Assert: func(result *R) { + result.Expect( + "expected true", + ) + }, + }, + {Scenario: "subsequent tests are not evaluated when test fails", + Act: func() { + result := require.Type[int]("string") + Expect(result).To(Equal(0)) + Expect(false).To(BeTrue()) + }, + Assert: func(result *R) { + result.Expect( + "expected type: int", + "got : string", + ) + }, + }, + }...)) +} + +func ExampleType() { + test.Example() + + // Type returns the value as the expected type when it + // is of that type + var got any = 1 / 2.0 + + result := require.Type[float64](got) + + fmt.Printf("result: type is: %T\n", result) + fmt.Printf("result: value is: %v\n", result) + + // Type terminates the current test if the value is not + // of the required type + got = "1 / 2.0" + + require.Type[float64](got) + + Expect(false, "this will not be evaluated").To(BeTrue()) + + //Output: + // result: type is: float64 + // result: value is: 0.5 + // + // expected type: float64 + // got : string +} diff --git a/run.go b/run.go index 6b49b3e..7e2c6b5 100644 --- a/run.go +++ b/run.go @@ -6,10 +6,16 @@ import ( "github.com/blugnu/test/internal/testframe" ) +// Runnable is an interface implemented by types that can be executed +// in a test frame using the [Run] function provided by the interface. type Runnable interface { Run() } +// Run executes the provided [Runnable] in the current test frame. +// +// It must be called from a Test..(*testing.T) func; it is not +// supported in Example..() funcs. func Run(r Runnable) { t, ok := testframe.Peek[*testing.T]() if !ok { diff --git a/run_test.go b/run_test.go index 79a7445..e675c98 100644 --- a/run_test.go +++ b/run_test.go @@ -30,7 +30,7 @@ func TestRun_Test(t *testing.T) { }) Expect(result.FailedTests).To(ContainItem("TestRun_Test/named_test")) - result.Expect("expected true, got false") + result.Expect("expected true") } func TestRun_Testcases(t *testing.T) { @@ -187,7 +187,7 @@ func TestRun_Testcases(t *testing.T) { )) }) - result.Expect(TestFailed, "expected 5, got 4") + result.Expect(test.Failed, "expected 5, got 4") })) Run(Test("skip method", func() { diff --git a/run-flaky.go b/runnable-flaky.go similarity index 93% rename from run-flaky.go rename to runnable-flaky.go index f839b4c..c979884 100644 --- a/run-flaky.go +++ b/runnable-flaky.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" "time" + + "github.com/blugnu/test/test" ) // flakyRunner runs a test function that is prone to flakiness. It implements @@ -32,7 +34,7 @@ func (tr flakyRunner) Run() { T().Helper() outcome, attempts, reports, elapsed := tr.runTestWithRetries() - if outcome == TestPassed { + if outcome == test.Passed { return } @@ -60,12 +62,12 @@ func (tr flakyRunner) Run() { // test, the number of attempts made, the reports of each failed attempt, and the // total elapsed time. // -// If the test passes, the reports are discarded and the outcome is [TestPassed]. +// If the test passes, the reports are discarded and the outcome is [test.Passed]. // If the test fails after all attempts, the reports contain the details of each -// failed attempt, and the outcome is [TestFailed]. +// failed attempt, and the outcome is [test.Failed]. // // The elapsed time is truncated to milliseconds for consistency in reporting. -func (tr flakyRunner) runTestWithRetries() (TestOutcome, uint, [][]string, time.Duration) { +func (tr flakyRunner) runTestWithRetries() (test.Outcome, uint, [][]string, time.Duration) { T().Helper() var ( @@ -75,10 +77,10 @@ func (tr flakyRunner) runTestWithRetries() (TestOutcome, uint, [][]string, time. reports [][]string ) - handleOutcome := func(outcome TestOutcome) (TestOutcome, uint, [][]string, time.Duration) { + handleOutcome := func(outcome test.Outcome) (test.Outcome, uint, [][]string, time.Duration) { elapsed = elapsed.Truncate(time.Millisecond) - if outcome == TestPassed { + if outcome == test.Passed { reports = nil // discard reports if the test passed } @@ -103,8 +105,8 @@ func (tr flakyRunner) runTestWithRetries() (TestOutcome, uint, [][]string, time. elapsed = time.Since(start) attempt++ - if result.Outcome == TestPassed { - return handleOutcome(TestPassed) + if result.Outcome == test.Passed { + return handleOutcome(test.Passed) } reports = append(reports, result.Report) @@ -112,7 +114,7 @@ func (tr flakyRunner) runTestWithRetries() (TestOutcome, uint, [][]string, time. time.Sleep(tr.wait) } - return handleOutcome(TestFailed) + return handleOutcome(test.Failed) } // FlakyOption is an option function type for an option that modifies diff --git a/run-flaky_test.go b/runnable-flaky_test.go similarity index 76% rename from run-flaky_test.go rename to runnable-flaky_test.go index de7d8ad..9bd3269 100644 --- a/run-flaky_test.go +++ b/runnable-flaky_test.go @@ -5,6 +5,7 @@ import ( "time" . "github.com/blugnu/test" + "github.com/blugnu/test/test" ) func TestFlaky(t *testing.T) { @@ -19,16 +20,16 @@ func TestFlaky(t *testing.T) { "Flaky test failed after 3 attempts", "", "attempt 1:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", "", "attempt 2:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", "", "attempt 3:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", ) result = TestHelper(func() { @@ -41,12 +42,12 @@ func TestFlaky(t *testing.T) { "Flaky test failed after 2 attempts in 1.0", "", "attempt 1:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", "", "attempt 2:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", ) result = TestHelper(func() { @@ -58,7 +59,7 @@ func TestFlaky(t *testing.T) { } })) }) - result.Expect(TestPassed) + result.Expect(test.Passed) result = TestHelper(func() { Run(FlakyTest("configured max attempts", func() { @@ -69,8 +70,8 @@ func TestFlaky(t *testing.T) { "Flaky test failed after 1 attempt", "", "attempt 1:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", ) result = TestHelper(func() { @@ -83,8 +84,8 @@ func TestFlaky(t *testing.T) { "Flaky test failed after 1 attempt in 2", // precise time is not guaranteed but should be at least 2(00ms) "", "attempt 1:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", ) result = TestHelper(func() { @@ -96,7 +97,7 @@ func TestFlaky(t *testing.T) { "Flaky test failed after 1 attempt", "", "attempt 1:", - " run-flaky_test.go", - " expected true, got false", + " runnable-flaky_test.go", + " expected true", ) } diff --git a/run-helper-tests.go b/runnable-helper-tests.go similarity index 96% rename from run-helper-tests.go rename to runnable-helper-tests.go index db3d042..fcd8ad3 100644 --- a/run-helper-tests.go +++ b/runnable-helper-tests.go @@ -37,7 +37,7 @@ func (r helpertestRunner) Run() { result := TestHelper(scn.Act) if scn.Assert == nil { - result.Expect(TestPassed) + result.Expect(test.Passed) return } diff --git a/run-helper-tests_test.go b/runnable-helper-tests_test.go similarity index 100% rename from run-helper-tests_test.go rename to runnable-helper-tests_test.go diff --git a/run-test.go b/runnable-test.go similarity index 64% rename from run-test.go rename to runnable-test.go index 19ac3f0..639731e 100644 --- a/run-test.go +++ b/runnable-test.go @@ -3,30 +3,42 @@ package test import ( "testing" + "github.com/blugnu/test/internal/testframe" "github.com/blugnu/test/test" ) -// testRunner is a simple wrapper around a test function that allows it to -// be run as a subtest in the current test frame. It implements the Runnable -// interface, allowing it to be used with the Run() function. +// testRunner implements [Runnable] to execute a function with an associated +// test name. type testRunner struct { name string fn func() parallel bool } +func newTestRunner(name string, fn func(), parallel bool) testRunner { + return testRunner{name: name, fn: fn, parallel: parallel} +} + +type Helper interface { + Helper() +} + // Run runs the named test function as a subtest in the current test frame func (tr testRunner) Run() { - t := T() + type T interface { + Helper() + Run(string, func(*testing.T)) bool + } + t := testframe.MustPeek[T]() t.Helper() - if tr.parallel && IsParallel() { + if tr.parallel && testframe.IsParallel() { test.Invalid("ParallelTest() cannot be run from a parallel test") } t.Run(tr.name, func(t *testing.T) { t.Helper() - With(t) + testframe.PushWithCleanup(t) if tr.parallel { t.Parallel() @@ -43,25 +55,17 @@ func (tr testRunner) Run() { // fail the test as invalid since it is not allowed to nest parallel // tests. func ParallelTest(name string, fn func()) testRunner { - T().Helper() - - if IsParallel() { + if testframe.IsParallel() { + T().Helper() test.Invalid("ParallelTest() cannot be run from a parallel test") } - return testRunner{ - name: name, - fn: fn, - parallel: true, - } + return newTestRunner(name, fn, true) } // Test creates a named test runner that can be used to run a test function as a // subtest in the current test frame. The name is used as the subtest name, // and the function is the test code to be executed. func Test(name string, fn func()) testRunner { - return testRunner{ - name: name, - fn: fn, - } + return newTestRunner(name, fn, false) } diff --git a/run-testcases.go b/runnable-testcases.go similarity index 86% rename from run-testcases.go rename to runnable-testcases.go index d8612ec..83a2e1a 100644 --- a/run-testcases.go +++ b/runnable-testcases.go @@ -23,18 +23,27 @@ type TestExecutor[T any] interface { // a default name is derived in the format "testcase-NNN" where NNN is // the 1-based index of the test case in the list of test cases. // -// If the test case data has a bool debug/Debug field that is set true, the -// test case will be marked as a debug test case. +// If the test case data has a bool field named debug/Debug that is set true, +// the test case will be marked as a debug test case. When 1 or more debug test +// cases are detected only those cases will be executed. // -// If the test case data has a bool skip/Skip field that is set true, the -// test case will be marked as a skip test case. +// If the test case data has a bool field named skip/Skip that is set true, +// the test case will be skipped, even if it is also marked as a debug test case. func Case[T any](name string, tc T) testcase.Registration[T] { return func(r *testcase.Runner[T], flags testcase.Flags) { r.AddCase(name, tc, flags) } } -// Cases creates a Runner to run a set of test cases. +// Cases registers a slice of test cases with the runner. This is a convenience +// function that is equivalent to calling Case() for each test case in the slice +// with an empty name. +// +// If the test case type T is a struct (or pointer to a struct) and has a +// string field named "Scenario", "scenario", "Name", or "name", with a value +// that is not empty and not whitespace, that will be used as the name. Otherwise +// a default name is derived in the format "testcase-NNN" where NNN is the 1-based +// index of the test case in the list of test cases. func Cases[T any](cases []T) testcase.Registration[T] { return func(r *testcase.Runner[T], flags testcase.Flags) { for _, c := range cases { @@ -43,7 +52,7 @@ func Cases[T any](cases []T) testcase.Registration[T] { } } -// Debug adds a test case to the runner and marks it as a debug target. +// Debug adds a test case to the runner and marks it as a debug case. // // When running test cases, if any cases are marked as debug only those // cases will be run. This is useful for debugging specific test cases diff --git a/skipNow.go b/skip-now.go similarity index 100% rename from skipNow.go rename to skip-now.go diff --git a/skipNow_test.go b/skip-now_test.go similarity index 100% rename from skipNow_test.go rename to skip-now_test.go diff --git a/stopWatch.go b/stop-watch.go similarity index 100% rename from stopWatch.go rename to stop-watch.go diff --git a/test-helper.go b/test-helper.go index afa1208..a17ec38 100644 --- a/test-helper.go +++ b/test-helper.go @@ -15,14 +15,6 @@ import ( "github.com/blugnu/test/test" ) -type TestOutcome int - -const ( - TestPassed TestOutcome = iota - TestFailed - TestPanicked -) - const ( cFailedTests = "failed tests" cTestOutcome = "test outcome" @@ -30,19 +22,6 @@ const ( cRecovered = "recovered" ) -func (to TestOutcome) String() string { - switch to { - case TestPassed: - return "TestPassed" - case TestFailed: - return "TestFailed" - case TestPanicked: - return "TestPanicked" - default: - return fmt.Sprintf("TestOutcome(%d)", to) - } -} - // R is a struct that contains the result of executing a test function. type R struct { // value recovered if the test execution caused a panic @@ -55,8 +34,8 @@ type R struct { Log []string // test outcome - // (TestPassed, TestFailed, TestPanicked) - Outcome TestOutcome + // (test.Passed, test.Failed, test.Panicked) + Outcome test.Outcome // names of any tests that failed FailedTests []string @@ -74,7 +53,7 @@ type R struct { // At least one argument must be provided to Expect() to specify the expected // outcome of the test. The arguments can be: // -// - a TestOutcome value (TestPassed, TestFailed, TestPanicked) +// - a test.Outcome value (test.Passed, test.Failed, test.Panicked) // - a string or slice of strings that are expected to be present in the test report // - a combination of the above, with options to control the assertion behavior // @@ -87,15 +66,15 @@ type R struct { // If no arguments are provided, Expect() will fail the test with an error message // indicating that at least one expected outcome or report line is required. // -// If the test outcome is expected to be TestPanicked, the first argument must be a -// TestOutcome value (TestPanicked) with a single string argument that is expected to +// If the test outcome is expected to be test.Panicked, the first argument must be a +// test.Outcome value (test.Panicked) with a single string argument that is expected to // match the string representation (%v) of the value recovered from the panic. func (r *R) Expect(exp ...any) { r.t.Helper() if len(exp) == 0 { // if no arguments are given, we expect the test to have passed - test.Invalid("R.Expect: no arguments; an expected TestOutcome and/or test report are required") + test.Invalid("R.Expect: no arguments; an expected test.Outcome and/or test report are required") } // we have some expectations so can mark the result as having been checked @@ -112,7 +91,7 @@ func (r *R) Expect(exp ...any) { r.assertOutcome(expectedOutcome, opts...) switch { - case expectedOutcome == TestPanicked: + case expectedOutcome == test.Panicked: r.assertPanicked(expectedReport, opts...) case len(expectedReport) > 0: @@ -142,15 +121,15 @@ func (r *R) Expect(exp ...any) { } // now we check that the report contains the expected lines - Expect(r.Report).To(ContainSlice(expectedReport), + Expect(r.Report, "test report").To(ContainSlice(expectedReport), append(opts, strings.Contains, opt.UnquotedStrings())..., ) - case expectedOutcome == TestFailed && opt.IsSet(opts, opt.IgnoreReport(true)): + case expectedOutcome == test.Failed && opt.IsSet(opts, opt.IgnoreReport(true)): Expect(r.FailedTests, cFailedTests).To(ContainItem(r.t.Name()), opts...) default: - r.assertWhenTestPassed(opts...) + r.TestPassed(opts...) } } @@ -176,7 +155,7 @@ func (r *R) Expect(exp ...any) { // // When testing for an invalid test: // -// - the test outcome is expected to be TestFailed +// - the test outcome is expected to be test.Failed // - the test report is expected to start with the '<== INVALID TEST' label // - the test report is expected to contain any report lines specified func (r *R) ExpectInvalid(report ...any) { @@ -191,15 +170,45 @@ func (r *R) ExpectWarning(msg string) { r.Expect("<== WARNING: " + msg) } +// TestPassed checks that there were no failed tests and no report +func (r *R) TestPassed(opts ...any) { + T().Helper() + + // in this case, we want the failure report to provide details of + // any unexpectedly failed tests and an unexpected report, so we + // remove any IsRequired option to ensure that both expectations + // are evaluated + opts = opt.Unset(opts, opt.IsRequired(true)) + + Expect(r.FailedTests, cFailedTests).Should(BeEmptyOrNil(), opts...) + + Expect(r.Report, cTestReport).Should(BeEmptyOrNil(), + append(opts, opt.FailureReport(func(...any) []string { + const preambleLen = 2 + + report := make([]string, preambleLen, len(r.Report)+preambleLen) + report[0] = "expected: " + report[1] = "got:" + for _, s := range r.Report { + report = append(report, "| "+s) + } + + return report + }))..., + ) +} + // analyseArgs processes the expected arguments passed to Expect(). -// It separates the expected report strings from the options and determines -// the expected outcome of the test based on the options provided. -// It returns the expected outcome, the report strings, and the options. -func (r *R) analyseArgs(exp ...any) (TestOutcome, []string, []any) { +// Any expected report strings are separated from the options and +// the expected outcome of the test is determined, based on the options. +// +// The function returns the expected outcome, the expected report +// strings, and the options. +func (r *R) analyseArgs(exp ...any) (test.Outcome, []string, []any) { var ( + outcome = test.Passed report []string opts []any - outcome = TestPassed ) // separate the expected report strings from any options @@ -214,10 +223,10 @@ func (r *R) analyseArgs(exp ...any) (TestOutcome, []string, []any) { } } - if opt.IsSet(opts, TestPanicked) { - outcome = TestPanicked - } else if opt.IsSet(opts, TestFailed) || len(report) > 0 { - outcome = TestFailed + if opt.IsSet(opts, test.Panicked) { + outcome = test.Panicked + } else if opt.IsSet(opts, test.Failed) || len(report) > 0 { + outcome = test.Failed } return outcome, report, opts @@ -226,7 +235,7 @@ func (r *R) analyseArgs(exp ...any) (TestOutcome, []string, []any) { // assertOutcome checks that the test outcome matches the expected outcome. // // The function is used internally by Expect() to verify the test outcome. -func (r *R) assertOutcome(expected TestOutcome, opts ...any) { +func (r *R) assertOutcome(expected test.Outcome, opts ...any) { T().Helper() Expect(r.Outcome, cTestOutcome).To(Equal(expected), @@ -237,7 +246,7 @@ func (r *R) assertOutcome(expected TestOutcome, opts ...any) { } switch { - case r.Outcome == TestPanicked: + case r.Outcome == test.Panicked: report = append(report, "") report = append(report, "recovered:") report = append(report, fmt.Sprintf(" %[1]T(%[1]v)", r.Recovered)) @@ -289,39 +298,11 @@ func (r *R) assertPanicked(expectedReport []string, opts ...any) { ) } -// assertWhenTestPassed checks that there were no failed tests and no report -func (r *R) assertWhenTestPassed(opts ...any) { - T().Helper() - - // in this case, we want the failure report to provide details of - // any unexpectedly failed tests and an unexpected report, so we - // remove any IsRequired option to ensure that both expectations - // are evaluated - opts = opt.Unset(opts, opt.IsRequired(true)) - - Expect(r.FailedTests, cFailedTests).Should(BeEmptyOrNil(), opts...) - - Expect(r.Report, cTestReport).Should(BeEmptyOrNil(), - append(opts, opt.FailureReport(func(...any) []string { - const preambleLen = 2 - - report := make([]string, preambleLen, len(r.Report)+preambleLen) - report[0] = "expected: " - report[1] = "got:" - for _, s := range r.Report { - report = append(report, "| "+s) - } - - return report - }))..., - ) -} - // TestHelper runs a function that executes a function in an internal test runner, // independent of the current test, returning an R value that captures the // following: // -// - the test outcome (TestPassed, TestFailed, TestPanicked) +// - the test outcome (test.Passed, test.Failed, test.Panicked) // - names of any tests that failed // - stdout output // - stderr output @@ -363,7 +344,7 @@ func TestHelper(f func()) R { }) if recovered != nil { - outcome = TestPanicked + outcome = test.Panicked } _, report, failed := analyseReport(stdout) @@ -393,17 +374,17 @@ func runInternalMatchAll(pat, match string) (bool, error) { // It is used to run a test function in a separate test runner in order to // inspect the outcome of the test function without that affecting the state of the // current test. -func runInternal(t *testing.T, f func(*testing.T)) ([]string, []string, TestOutcome) { +func runInternal(t *testing.T, f func(*testing.T)) ([]string, []string, test.Outcome) { t.Helper() - result := TestFailed + result := test.Failed stdout, stderr := Record(func() { it := []testing.InternalTest{{ Name: t.Name(), F: f, }} if testing.RunTests(runInternalMatchAll, it) { - result = TestPassed + result = test.Passed } }) diff --git a/test-helper_test.go b/test-helper_test.go index 2839488..e8bb99e 100644 --- a/test-helper_test.go +++ b/test-helper_test.go @@ -7,30 +7,6 @@ import ( "github.com/blugnu/test/test" ) -func TestTestOutcome(t *testing.T) { - With(t) - - type testcase struct { - TestOutcome - result string - } - Run(Testcases( - ForEach(func(tc testcase) { - // ACT - result := tc.String() - - // ASSERT - Expect(result).To(Equal(tc.result)) - }), - Cases([]testcase{ - {TestOutcome: TestPassed, result: "TestPassed"}, - {TestOutcome: TestFailed, result: "TestFailed"}, - {TestOutcome: TestPanicked, result: "TestPanicked"}, - {TestOutcome: 99, result: "TestOutcome(99)"}, - }), - )) -} - func TestTestHelper(t *testing.T) { With(t) @@ -49,7 +25,7 @@ func TestTestHelper(t *testing.T) { result := TestHelper(func() { panic("whoops!") }) - result.Expect(TestPanicked, "whoops!") + result.Expect(test.Panicked, "whoops!") })) Run(Test("additional output to stdout", func() { @@ -62,7 +38,7 @@ func TestTestHelper(t *testing.T) { }) // the report consists only of test report output - result.Expect(TestFailed, "expected false, got true") + result.Expect(test.Failed, "expected false") })) } @@ -74,42 +50,42 @@ func TestR_Expect(t *testing.T) { Act: func() { sut := R{ t: T(), - Outcome: TestPassed, + Outcome: test.Passed, } sut.Expect() }, Assert: func(result *R) { - result.ExpectInvalid("R.Expect: no arguments; an expected TestOutcome and/or test report are required") + result.ExpectInvalid("R.Expect: no arguments; an expected test.Outcome and/or test report are required") }, }, {Scenario: "expected to panic (no report)", Act: func() { sut := R{ t: T(), - Outcome: TestPanicked, + Outcome: test.Panicked, Recovered: "recovered", } - sut.Expect(TestPanicked) + sut.Expect(test.Panicked) }, }, {Scenario: "expected to panic (matched recovered string)", Act: func() { sut := R{ t: T(), - Outcome: TestPanicked, + Outcome: test.Panicked, Recovered: "recovered", } - sut.Expect(TestPanicked, "recovered") + sut.Expect(test.Panicked, "recovered") }, }, {Scenario: "expected to panic (too many strings specified)", Act: func() { sut := R{ t: T(), - Outcome: TestPanicked, + Outcome: test.Panicked, Recovered: "recovered", } - sut.Expect(TestPanicked, "recovered", "and a second string") + sut.Expect(test.Panicked, "recovered", "and a second string") }, Assert: func(result *R) { result.ExpectInvalid("R.Expect: only 1 string may be specified to match a recovered value from an expected panic (got 2)") @@ -119,17 +95,17 @@ func TestR_Expect(t *testing.T) { Act: func() { sut := R{ t: T(), - Outcome: TestPanicked, + Outcome: test.Panicked, Recovered: "recovered", Stack: []byte("panic(\n runtime.panic.go\nstack trace\n with_call_sites.go:123:456"), } - sut.Expect(TestPassed) + sut.Expect(test.Passed) }, Assert: func(result *R) { result.Expect( "test outcome:", - " expected: TestPassed", - " got : TestPanicked", + " expected: test.Passed", + " got : test.Panicked", "", "recovered:", " string(recovered)", @@ -143,15 +119,15 @@ func TestR_Expect(t *testing.T) { Act: func() { sut := R{ t: T(), - Outcome: TestFailed, + Outcome: test.Failed, } - sut.Expect(TestPassed) + sut.Expect(test.Passed) }, Assert: func(result *R) { result.Expect( "test outcome:", - " expected: TestPassed", - " got : TestFailed", + " expected: test.Passed", + " got : test.Failed", ) }, }, @@ -159,16 +135,16 @@ func TestR_Expect(t *testing.T) { Act: func() { sut := R{ t: T(), - Outcome: TestFailed, + Outcome: test.Failed, Report: []string{"actual report"}, } - sut.Expect(TestPassed) + sut.Expect(test.Passed) }, Assert: func(result *R) { result.Expect( "test outcome:", - " expected: TestPassed", - " got : TestFailed", + " expected: test.Passed", + " got : test.Failed", "with report:", "| actual report", ) @@ -178,10 +154,10 @@ func TestR_Expect(t *testing.T) { Act: func() { sut := R{ t: T(), - Outcome: TestFailed, + Outcome: test.Failed, Report: []string{"actual report"}, } - sut.Expect(TestFailed) + sut.Expect(test.Failed) }, Assert: func(result *R) { result.Expect( @@ -196,14 +172,32 @@ func TestR_Expect(t *testing.T) { Act: func() { sut := R{ t: T(), - Outcome: TestFailed, + Outcome: test.Failed, } - sut.Expect(TestFailed, "some expected failure report") + sut.Expect(test.Failed, "some expected failure report") }, Assert: func(result *R) { result.ExpectWarning("test failed as expected, but no test report or failures were recorded") }, }, + {Scenario: "expected report as slice", + Act: func() { + sut := R{ + t: T(), + Outcome: test.Failed, + FailedTests: []string{"TestR_Expect/expected_report_as_slice"}, + Report: []string{ + "test-helper_test.go:999", // actual line number will vary and is ignored + " actual report", + " spans two lines", + }, + } + sut.Expect([]string{ + "actual report", + "spans two lines", + }) + }, + }, }...)) } @@ -232,7 +226,8 @@ func Test_runInternal(t *testing.T) { // the output to avoid false negatives in tests that expect an empty output for // a passing test. - t := RequireType[*testing.T](T()) + t, ok := T().(*testing.T) + Expect(ok).To(BeTrue()) // ACT stdout, stderr, outcome := runInternal(t, func(_ *testing.T) { @@ -243,7 +238,7 @@ func Test_runInternal(t *testing.T) { }) // ASSERT - Expect(outcome).To(Equal(TestPassed)) + Expect(outcome).To(Equal(test.Passed)) Expect(stdout).Should(BeEmpty()) Expect(stderr).Should(BeNil()) })) diff --git a/test/invalid.go b/test/invalidate.go similarity index 63% rename from test/invalid.go rename to test/invalidate.go index cadc5d2..c5c8cdc 100644 --- a/test/invalid.go +++ b/test/invalidate.go @@ -16,6 +16,45 @@ type runner interface { FailNow() } +// Error is used to indicate a test is invalid due to some error having occurred. +// This is intended to be used in test helpers and matchers to report an error +// that invalidates a test. +// +// i.e. the error does not indicate that the test failed, but rather that +// the test is invalid and therefore unreliable, due to an error that occurred +// during execution or evaluation of a test helper or matcher. +// +// It should not be confused with the [github.com/blugnu/test.Error] or +// [github.com/blugnu/test.Errorf] functions. +// +// If a valid test frame is available, it will report the error using the [Errorf] +// method of that test frame. +// +// If no valid test frame is available, this function will panic with the +// provided error and message(s). Panicking ensures that test execution fails, +// avoiding a false positive outcome. +// +// # Alternatives +// +// To indicate that a test is invalid without any specific error, use the [Invalid] +// function. +// +// To draw attention to a non-fatal issue in a test, use the [Warning] function. +func Error(err error, msg ...string) { + if s := strings.Join(msg, "\n"); len(s) > 0 { + err = fmt.Errorf("%w\n%s", err, s) + } + + if t, ok := testframe.Peek[runner](); ok { + t.Helper() + t.Errorf("<== INVALID TEST\nERROR: %s", err.Error()) + t.FailNow() + return + } + + panic(fmt.Errorf("INVALID TEST\n%w", err)) +} + // Invalid is used to mark a test as invalid. It should be called by a matcher // when the test cannot be run due to an invalid condition, such as attempting to // use a matcher with an unsupported type, or when the test is not properly set up. @@ -36,54 +75,28 @@ func Invalid(msg ...string) { // if we can obtain a TestRunner from the current test frame then we will // use it to report the test as invalid, otherwise we must panic, to avoid // a test yielding a false positive result - t, ok := testframe.Peek[runner]() - if !ok { - panic(s) - } - - t.Helper() - t.Errorf("<== %s", s) - t.FailNow() -} - -// Error is used to indicate an error in a test. This should be used to report -// errors that occur during the execution of a test rendering the test outcome -// invalid. -// -// i.e. the error does not indicate that the test failed, but rather that -// the test is invalid and therefore unreliable, due to an error that occurred -// during its execution. -// -// If a valid test frame is available, it will report the error using the Error -// method. -// -// If no valid test frame is available, this function will panic with the -// provided error and message(s), avoiding a false positive test outcome. -func Error(err error, msg ...string) { - t, tok := testframe.Peek[runner]() - s := strings.Join(msg, "\n") - - switch { - // we have a t we can use to report the error - case tok && len(s) > 0: - t.Helper() - t.Errorf("<== INVALID TEST\nERROR: %s\n%s", err.Error(), s) - case tok: + if t, ok := testframe.Peek[runner](); ok { t.Helper() - t.Errorf("<== INVALID TEST\nERROR: %s", err.Error()) - - // no t, we must panic - case len(s) > 0: - panic(fmt.Errorf("INVALID TEST\n%w\n%s", err, s)) - default: - panic(fmt.Errorf("INVALID TEST\n%w", err)) + t.Errorf("<== %s", s) + t.FailNow() + return } + + panic(s) } // Warning is used to report a warning in a test. This should be used to // indicate a condition that is not an error, but may indicate a problem or // unexpected behavior in the test. // +// Although a warning does not invalidate a test, it does fail any current +// test execution. +// +// For example, if all test cases in a table-driven test are skipped, a +// warning is produced indicating that the test did not run any test cases. +// This does not represent an error or failure in any individual test case, +// but fails the test, avoiding a false positive result. +// // If a valid test frame is available, it will report the warning using the // Errorf, otherwise it will panic with the warning message. // @@ -97,11 +110,11 @@ func Warning(msg string) { // if we can obtain a TestRunner from the current test frame then we will // use it to report the test as invalid, otherwise we must panic, to avoid // a test yielding a false positive result - t, ok := testframe.Peek[runner]() - if !ok { - panic(msg) + if t, ok := testframe.Peek[runner](); ok { + t.Helper() + t.Errorf("<== " + msg) + return } - t.Helper() - t.Errorf("<== " + msg) + panic(msg) } diff --git a/test/invalid_test.go b/test/invalidate_test.go similarity index 100% rename from test/invalid_test.go rename to test/invalidate_test.go diff --git a/test/outcome.go b/test/outcome.go new file mode 100644 index 0000000..48e9793 --- /dev/null +++ b/test/outcome.go @@ -0,0 +1,24 @@ +package test + +import "fmt" + +type Outcome int + +const ( + Passed Outcome = iota + Failed + Panicked +) + +func (to Outcome) String() string { + switch to { + case Passed: + return "test.Passed" + case Failed: + return "test.Failed" + case Panicked: + return "test.Panicked" + default: + return fmt.Sprintf("test.Outcome(%d)", to) + } +} diff --git a/test/outcome_test.go b/test/outcome_test.go new file mode 100644 index 0000000..f6140ea --- /dev/null +++ b/test/outcome_test.go @@ -0,0 +1,32 @@ +package test_test + +import ( + "testing" + + . "github.com/blugnu/test" + "github.com/blugnu/test/test" +) + +func TestOutcome(t *testing.T) { + With(t) + + type testcase struct { + test.Outcome + result string + } + Run(Testcases( + ForEach(func(tc testcase) { + // ACT + result := tc.String() + + // ASSERT + Expect(result).To(Equal(tc.result)) + }), + Cases([]testcase{ + {Outcome: test.Passed, result: "test.Passed"}, + {Outcome: test.Failed, result: "test.Failed"}, + {Outcome: test.Panicked, result: "test.Panicked"}, + {Outcome: 99, result: "test.Outcome(99)"}, + }), + )) +} diff --git a/testframe.go b/testframe.go index 770243c..01fb622 100644 --- a/testframe.go +++ b/testframe.go @@ -98,9 +98,5 @@ func With(t TestingT) { panic(testframe.ErrNoTestFrame) } - testframe.Push(t) - - t.Cleanup(func() { - testframe.Pop() - }) + testframe.PushWithCleanup(t) } diff --git a/type.go b/type.go deleted file mode 100644 index 7f5e85b..0000000 --- a/type.go +++ /dev/null @@ -1,87 +0,0 @@ -package test - -import ( - "fmt" - "reflect" - - "github.com/blugnu/test/matchers/typecheck" - "github.com/blugnu/test/opt" -) - -// ExpectType tests that a value is of an expected type. If the test passes, -// the value is returned as that type, with true. If the test fails the zero -// value of the specified type is returned, with false. -// -// If the value being of the expected type is essential to the test, consider -// using the [RequireType] function instead, which will return the value or -// fail the test immediately, avoiding the need to check the ok value. -// -// If a test does not use the returned value, consider using the [BeOfType] -// matcher instead, to avoid lint warnings about unused return values. -func ExpectType[T any](got any, opts ...any) (T, bool) { - GetT().Helper() - - result, ok := got.(T) - if ok { - return result, true - } - - var ( - zero T - expectedType = fmt.Sprintf("%T", zero) - ) - - // if we could not determine the type of the expected value using the - // zero value of the type, we can use a dummy function and reflect - // the type of the first argument to that function. - // - // Q: why not just use the dummy func technique every time? - // A: because it is more expensive than using the zero value, and - // using the zero value provides a more precise (package-qualified) - // type name, when successful - if expectedType == "" { - fn := func(T) { /* NO-OP */ } - fn(zero) // ensures that the dummy function is 'covered' by tests - - expectedType = reflect.TypeOf(fn).In(0).Name() - } - - gotType := fmt.Sprintf("%T", got) - - opts = append(opts, - opt.FailureReport(func(...any) []string { - return []string{ - "expected type: " + expectedType, - "got : " + gotType, - } - }), - ) - - Expect(gotType, opts...).To(Equal(expectedType), opts...) - - return zero, false -} - -// RequireType tests that a value is of an expected type. If the test passes, -// the value is returned as that type otherwise the test fails immediately -// without evaluating any further expectations. -// -// If a test does not use the returned value, consider using the [BeOfType] -// matcher instead, to avoid lint warnings about unused return values. -func RequireType[T any](got any, opts ...any) T { - GetT().Helper() - - z, _ := ExpectType[T](got, append(opts, opt.Required())...) - - return z -} - -// BeOfType returns a matcher that checks if the value is of the expected type. -// -// If a test wishes to perform further tests on the value that rely on having a -// value of the expected type, consider using the [ExpectType] or [RequireType] -// functions instead, which will return the value as the expected -// type, or fail the test immediately. -func BeOfType[T any]() typecheck.Matcher[T] { - return typecheck.Matcher[T]{} -} diff --git a/type_test.go b/type_test.go deleted file mode 100644 index fb31e9e..0000000 --- a/type_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package test_test - -import ( - "errors" - "fmt" - "testing" - - . "github.com/blugnu/test" - "github.com/blugnu/test/test" -) - -func TestExpectType(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "expecting int got int", - Act: func() { - result, ok := ExpectType[int](1) - Expect(result).To(Equal(1)) - Expect(ok).To(BeTrue()) - }, - }, - {Scenario: "expecting int got string", - Act: func() { - result, ok := ExpectType[int]("string") - Expect(result).To(Equal(0)) - Expect(ok).To(BeFalse()) - }, - Assert: func(result *R) { - result.Expect( - "expected type: int", - "got : string", - ) - }, - }, - {Scenario: "expecting named int got bool", - Act: func() { - result, ok := ExpectType[int](false, "named value") - Expect(result).To(Equal(0)) - Expect(ok).To(BeFalse()) - }, - Assert: func(result *R) { - result.Expect([]string{ - "named value:", - " expected type: int", - " got : bool", - }) - }, - }, - {Scenario: "expecting error, got nil", - Act: func() { - result, ok := ExpectType[error](nil) - Expect(ok).To(BeFalse()) - Expect(result).Should(BeNil()) - }, - Assert: func(result *R) { - result.Expect( - "expected type: error", - "got : ", - ) - }, - }, - {Scenario: "expecting error, got error", - Act: func() { - result, ok := ExpectType[error](errors.New("an error occurred")) - Expect(ok).To(BeTrue()) - Expect(result.Error()).To(Equal("an error occurred")) - }, - }, - }...)) -} - -func TestRequireType(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "expecting int got int", - Act: func() { - result := RequireType[int](1) - Expect(result).To(Equal(1)) - }, - }, - {Scenario: "expecting int got string", - Act: func() { - RequireType[int]("string") - Expect(false, "this will not be evaluated").To(BeTrue()) - }, - Assert: func(result *R) { - result.Expect( - "expected type: int", - "got : string", - ) - }, - }, - }...)) -} - -func TestShould_BeOfType(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "int is int", - Act: func() { Expect(42).Should(BeOfType[int]()) }, - }, - {Scenario: "int is not string", - Act: func() { Expect(42).Should(BeOfType[string]()) }, - Assert: func(result *R) { - result.Expect( - "expected type: string", - "got : int", - ) - }, - }, - }...)) -} - -func TestShouldNot_BeOfType(t *testing.T) { - With(t) - - Run(HelperTests([]HelperScenario{ - {Scenario: "int is not string", - Act: func() { Expect(42).ShouldNot(BeOfType[string]()) }, - }, - {Scenario: "int is string", - Act: func() { Expect(42).ShouldNot(BeOfType[int]()) }, - Assert: func(result *R) { - result.Expect( - "should not be of type: int", - ) - }, - }, - }...)) -} - -func ExampleExpectType() { - test.Example() - - // ExpectType returns the value as the expected type and true if the - // value is of that type - var got any = 1 / 2.0 - result, ok := ExpectType[float64](got) - - fmt.Printf("ok is %v\n", ok) - fmt.Printf("result: type is: %T\n", result) - fmt.Printf("result: value is: %v\n", result) - - // ExpectType returns the zero value of the expected type and false if the - // value is not of that type (the return values can be ignored if the - // test is only concerned with checking the type) - got = "1 / 2.0" - ExpectType[float64](got) - - //Output: - // ok is true - // result: type is: float64 - // result: value is: 0.5 - // - // expected type: float64 - // got : string -} - -func ExampleRequireType() { - test.Example() - - // RequireType returns the value as the expected type when it - // is of that type - var got any = 1 / 2.0 - result := RequireType[float64](got) - - fmt.Printf("result: type is: %T\n", result) - fmt.Printf("result: value is: %v\n", result) - - // RequireType terminates the current test if the value is not - // of the required type - got = "1 / 2.0" - RequireType[float64](got) - Expect(false, "this will not be evaluated").To(BeTrue()) - - //Output: - // result: type is: float64 - // result: value is: 0.5 - // - // expected type: float64 - // got : string -}