-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
bfe6aea
commit 9917d03
Showing
7 changed files
with
417 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
name: CI | ||
|
||
on: | ||
push: | ||
branches: | ||
- "main" | ||
pull_request: | ||
|
||
permissions: | ||
contents: read | ||
|
||
jobs: | ||
lint: | ||
name: lint | ||
runs-on: ubuntu-latest | ||
strategy: | ||
matrix: | ||
go-version: [ 'stable', 'oldstable' ] | ||
steps: | ||
- name: Checkout code | ||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 | ||
with: | ||
fetch-depth: 0 | ||
|
||
- name: Set up Go ${{ matrix.go-version }} | ||
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 | ||
with: | ||
go-version: ${{ matrix.go-version }} | ||
cache: true | ||
|
||
- name: Run golangci-lint | ||
uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64 # v6.0.1 | ||
with: | ||
version: latest | ||
|
||
test: | ||
name: test | ||
runs-on: ubuntu-latest | ||
strategy: | ||
matrix: | ||
go-version: [ 'stable', 'oldstable' ] | ||
steps: | ||
- name: Checkout code | ||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 | ||
|
||
- name: Set up Go ${{ matrix.go-version }} | ||
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 | ||
with: | ||
go-version: ${{ matrix.go-version }} | ||
cache: true | ||
|
||
- name: Verify dependencies | ||
run: go mod verify | ||
|
||
- name: check that 'go mod tidy' is clean | ||
run: | | ||
go mod tidy | ||
git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1) | ||
- name: Run tests | ||
run: go test -race -v ./... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
.DS_Store | ||
cover.out | ||
cover.html |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
linters: | ||
enable: | ||
- goimports | ||
- gosec |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
# Marker | ||
|
||
[![Go Reference](https://pkg.go.dev/badge/github.com/KasonBraley/marker.svg)](https://pkg.go.dev/github.com/KasonBraley/marker) | ||
|
||
This package provides a [slog.Handler](https://pkg.go.dev/log/slog#Handler) and an associated API for | ||
implementing explicit code coverage marks for _linking_ source code and tests together. | ||
|
||
In production code, you use your logger as normal, and can use it to say "this should be covered by a test". | ||
In test code, you can then assert that a _specific_ test covers a specific log line. | ||
|
||
The purpose of this is to help with test maintenance over time in larger projects. Large projects | ||
often have a lot of tests. Finding the tests for a specific piece of code, and vice versa, can be | ||
a challenge. This package provides a simple solution to that problem by leveraging your existing | ||
logger, and simply enabling the use of `grep` to search for a corresponding test. For example, if | ||
you see `logger.Debug("request sent, waiting on response")` in the code, you can grep for that log | ||
message and immediately find the test that goes with that code path. | ||
|
||
The blog post that inspired this package goes over this testing technique and why it's useful in much | ||
more detail. https://ferrous-systems.com/blog/coverage-marks/ | ||
|
||
This is not for "coverage". Coverage is the answer to the question "Is this tested?". | ||
Marks answer "Why does this code need to exist?". | ||
|
||
Inspired by: | ||
|
||
- https://ferrous-systems.com/blog/coverage-marks/ | ||
- https://en.wikipedia.org/wiki/Requirements_traceability | ||
|
||
Implementations of this concept in other languages: | ||
|
||
- [Rust](https://crates.io/crates/cov-mark) | ||
|
||
##### Example: | ||
|
||
```go | ||
package main | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"log/slog" | ||
|
||
"github.com/KasonBraley/marker" | ||
) | ||
|
||
func main() { | ||
run() | ||
} | ||
|
||
func run() { | ||
logger := slog.New(marker.NewHandler(slog.NewTextHandler(io.Discard, nil))) | ||
svc := newService(logger) | ||
svc.isEven(2) | ||
} | ||
|
||
type service struct { | ||
logger *slog.Logger | ||
} | ||
|
||
func newService(logger *slog.Logger) *service { | ||
return &service{logger: logger} | ||
} | ||
|
||
func (s *service) isEven(x int) { | ||
if x%2 == 0 { | ||
s.logger.Info(fmt.Sprintf("x is even (x=%v)", x)) | ||
} | ||
s.logger.Info(fmt.Sprintf("x is odd (x=%v)", x)) | ||
} | ||
``` | ||
|
||
Corresponding test: | ||
|
||
```go | ||
func TestIsEven(t *testing.T) { | ||
svc := newService() | ||
|
||
t.Run("even", func(t *testing.T) { | ||
mark := marker.Check("x is even") | ||
svc.isEven(2) | ||
if err := mark.ExpectHit(); err != nil { | ||
t.Error(err) | ||
} | ||
}) | ||
|
||
t.Run("odd", func(t *testing.T) { | ||
mark := marker.Check("x is even") // If we change this to "x is odd", it will pass. | ||
svc.isEven(3) // Odd number passed to show that we don't hit the expected mark. | ||
if err := mark.ExpectHit(); err != nil { | ||
t.Error(err) // The error `mark "x is even" not hit` is returned. | ||
} | ||
}) | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module github.com/KasonBraley/marker | ||
|
||
go 1.21 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
// Package marker provides a [slog.Handler] and an associated API for | ||
// implementing explicit code coverage marks for *linking* source code and tests together. | ||
// | ||
// In production code, you use your logger as normal, and can use it to say "this should be covered by a test". | ||
// In test code, you can then assert that a _specific_ test covers a specific log line. | ||
// | ||
// The purpose of this is to help with test maintenance over time in larger projects. Large projects | ||
// often have a lot of tests. Finding the tests for a specific piece of code, and vice versa, can be | ||
// a challenge. This package provides a simple solution to that problem by leveraging your existing | ||
// logger, and simply enabling the use of `grep` to search for a corresponding test. For example, if | ||
// you see `logger.Debug("request sent, waiting on response")` in the code, you can grep for that log | ||
// message and immediately find the test that goes with that code path. | ||
package marker | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"log/slog" | ||
"strings" | ||
"testing" | ||
) | ||
|
||
type state struct { | ||
markName *string | ||
markHit bool | ||
} | ||
|
||
// Stores the currently active mark and its hit count. | ||
// State is not synchronized and assumes single threaded execution. | ||
var globalState = state{} | ||
|
||
type handler struct { | ||
h slog.Handler | ||
} | ||
|
||
// NewHandler returns a [slog.Handler] implementation to help trace tests to source code. | ||
// In a test environment, reported by [testing.Testing], the [slog.Handler] returned records | ||
// that a log message was hit. | ||
// | ||
// In a test, [Check] is used to say that the code under test should log a specific message. It | ||
// returns a [Mark] where [Mark.ExpectHit] is expected to be called after the code under test | ||
// is ran. | ||
// | ||
// In non-tests(i.e. normal code operation), this recording of [Mark]'s is a no-op. | ||
func NewHandler(h slog.Handler) *handler { | ||
return &handler{h: h} | ||
} | ||
|
||
func (m *handler) Handle(ctx context.Context, r slog.Record) error { | ||
if testing.Testing() { | ||
recordMark(r.Message) | ||
} | ||
|
||
return m.h.Handle(ctx, r) | ||
} | ||
|
||
func (m *handler) Enabled(ctx context.Context, lvl slog.Level) bool { | ||
return m.h.Enabled(ctx, lvl) | ||
} | ||
|
||
func (m *handler) WithAttrs(attrs []slog.Attr) slog.Handler { | ||
return m.h.WithAttrs(attrs) | ||
} | ||
|
||
func (m *handler) WithGroup(name string) slog.Handler { | ||
return m.h.WithGroup(name) | ||
} | ||
|
||
func recordMark(msg string) { | ||
if globalState.markName != nil { | ||
activeMark := *globalState.markName | ||
if strings.Contains(msg, activeMark) { | ||
globalState.markHit = true | ||
} | ||
} | ||
} | ||
|
||
type Mark struct { | ||
name string | ||
} | ||
|
||
// Check stores the given mark name in global state to be subsequently asserted it was hit | ||
// with [Mark.ExpectHit]. | ||
// | ||
// Check will panic if not used in a testing environment, as reported by [testing.Testing]. | ||
func Check(name string) Mark { | ||
if !testing.Testing() { | ||
panic("mark: marker.Check can only be used in tests") | ||
} | ||
|
||
if globalState.markName != nil { | ||
// This is possible to happen, due to misuse of the API. For instance, this would occur | ||
// if two [Check] calls are called in a row without a corresponding [Mark.ExpectHit] call. | ||
// | ||
// Like: | ||
// mark := marker.Check("foo") | ||
// mark2 := marker.Check("foo2") | ||
// | ||
panic(fmt.Sprintf("mark: mark name %q should be nil, missing the corresponding ExpectHit call", name)) | ||
} | ||
|
||
if globalState.markHit != false { | ||
Check failure on line 102 in mark.go
|
||
// This should never happen. | ||
panic(fmt.Sprintf("mark: hit count should be false for mark %q", name)) | ||
} | ||
|
||
globalState.markName = &name | ||
return Mark{name: name} | ||
} | ||
|
||
// ExpectHit returns an error if the stored name on Mark was not hit. ExpectHit requires [Check] | ||
// to have been called first with the mark name that you expect to have been logged in the function | ||
// under test. | ||
// | ||
// ExpectHit will panic if not used in a testing environment, as reported by [testing.Testing]. | ||
func (m Mark) ExpectHit() error { | ||
if !testing.Testing() { | ||
panic("mark: ExpectHit can only be used in tests") | ||
} | ||
|
||
defer func() { | ||
globalState = state{} | ||
}() | ||
|
||
if globalState.markName == nil { | ||
// This occuring means incorrect use of the API. The [Check] function was not called first. | ||
panic("mark: ExpectHit called without first calling Check") | ||
} | ||
|
||
if globalState.markName != nil && *globalState.markName != m.name { | ||
// This should never happen. | ||
panic("mark: global state does not match the given Mark") | ||
} | ||
|
||
if globalState.markHit == false { | ||
Check failure on line 135 in mark.go
|
||
// This is the expected behavior if something went wrong. | ||
// Can be one of: | ||
// - The mark name in the test is wrong | ||
// - The mark name(log message) in the code under test is wrong | ||
// - Or in the real scenario this package is made for, the code under test was actually | ||
// not executed like it was expected to be. | ||
return fmt.Errorf("mark %q not hit", m.name) | ||
} | ||
|
||
return nil | ||
} |
Oops, something went wrong.