Skip to content

Commit

Permalink
neotest: implement coverage collection
Browse files Browse the repository at this point in the history
Test coverage is automatically enabled when go test is running with coverage
enabled. It can be disabled for any Executor by using relevant methods.
Coverage is gathered by capturing VM OPs during test contract execution and
mapping them to the contract source code using the DebugInfo information.

Signed-off-by: Slava0135 <[email protected]>
  • Loading branch information
Slava0135 committed Aug 16, 2024
1 parent 7766168 commit 5a1dd04
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 16 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ jobs:
cache: true

- name: Write coverage profile
run: go test -timeout 15m -v ./... -coverprofile=./coverage.txt -covermode=atomic -coverpkg=./pkg...,./cli/...
run: DISABLE_NEOTEST_COVER=1 go test -timeout 15m -v ./... -coverprofile=./coverage.txt -covermode=atomic -coverpkg=./pkg...,./cli/...

- name: Upload coverage results to Codecov
uses: codecov/codecov-action@v4
Expand Down
41 changes: 35 additions & 6 deletions pkg/neotest/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,23 @@ type Executor struct {
Validator Signer
Committee Signer
CommitteeHash util.Uint160
Contracts map[string]*Contract
// collectCoverage is true if coverage is being collected when running this executor.
collectCoverage bool
}

// NewExecutor creates a new executor instance from the provided blockchain and committee.
// By default coverage collection is enabled, but only when `go test` is running with coverage enabled.
// Use DisableCoverage and EnableCoverage to stop coverage collection for this executor when not desired.
func NewExecutor(t testing.TB, bc *core.Blockchain, validator, committee Signer) *Executor {
checkMultiSigner(t, validator)
checkMultiSigner(t, committee)

return &Executor{
Chain: bc,
Validator: validator,
Committee: committee,
CommitteeHash: committee.ScriptHash(),
Contracts: make(map[string]*Contract),
Chain: bc,
Validator: validator,
Committee: committee,
CommitteeHash: committee.ScriptHash(),
collectCoverage: isCoverageEnabled(),
}
}

Expand Down Expand Up @@ -145,6 +148,7 @@ func (e *Executor) DeployContract(t testing.TB, c *Contract, data any) util.Uint
// data is an optional argument to `_deploy`.
// It returns the hash of the deploy transaction.
func (e *Executor) DeployContractBy(t testing.TB, signer Signer, c *Contract, data any) util.Uint256 {
e.trackCoverage(t, c)
tx := e.NewDeployTxBy(t, signer, c, data)
e.AddNewBlock(t, tx)
e.CheckHalt(t, tx.Hash())
Expand All @@ -164,11 +168,22 @@ func (e *Executor) DeployContractBy(t testing.TB, signer Signer, c *Contract, da
// DeployContractCheckFAULT compiles and deploys a contract to the bc using the validator
// account. It checks that the deploy transaction FAULTed with the specified error.
func (e *Executor) DeployContractCheckFAULT(t testing.TB, c *Contract, data any, errMessage string) {
e.trackCoverage(t, c)
tx := e.NewDeployTx(t, c, data)
e.AddNewBlock(t, tx)
e.CheckFault(t, tx.Hash(), errMessage)
}

// trackCoverage switches on coverage tracking for provided script if `go test` is running with coverage enabled.
func (e *Executor) trackCoverage(t testing.TB, c *Contract) {
if e.collectCoverage {
addScriptToCoverage(c)
t.Cleanup(func() {
reportCoverage(t)
})

Check warning on line 183 in pkg/neotest/basic.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/basic.go#L180-L183

Added lines #L180 - L183 were not covered by tests
}
}

// InvokeScript adds a transaction with the specified script to the chain and
// returns its hash. It does no faults check.
func (e *Executor) InvokeScript(t testing.TB, script []byte, signers []Signer) util.Uint256 {
Expand Down Expand Up @@ -401,6 +416,10 @@ func (e *Executor) TestInvoke(tx *transaction.Transaction) (*vm.VM, error) {
ttx := *tx
ic, _ := e.Chain.GetTestVM(trigger.Application, &ttx, b)

if e.collectCoverage {
ic.VM.SetOnExecHook(coverageHook)

Check warning on line 420 in pkg/neotest/basic.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/basic.go#L420

Added line #L420 was not covered by tests
}

defer ic.Finalize()

ic.VM.LoadWithFlags(tx.Script, callflag.All)
Expand Down Expand Up @@ -431,3 +450,13 @@ func (e *Executor) GetTxExecResult(t testing.TB, h util.Uint256) *state.AppExecR
require.Equal(t, 1, len(aer))
return &aer[0]
}

// EnableCoverage enables coverage collection for this executor, but only when `go test` is running with coverage enabled.
func (e *Executor) EnableCoverage() {
e.collectCoverage = isCoverageEnabled()

Check warning on line 456 in pkg/neotest/basic.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/basic.go#L455-L456

Added lines #L455 - L456 were not covered by tests
}

// DisableCoverage disables coverage collection for this executor until enabled explicitly through EnableCoverage.
func (e *Executor) DisableCoverage() {
e.collectCoverage = false

Check warning on line 461 in pkg/neotest/basic.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/basic.go#L460-L461

Added lines #L460 - L461 were not covered by tests
}
8 changes: 8 additions & 0 deletions pkg/neotest/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func (c *ContractInvoker) TestInvokeScript(t testing.TB, script []byte, signers
}
t.Cleanup(ic.Finalize)

if c.collectCoverage {
ic.VM.SetOnExecHook(coverageHook)

Check warning on line 67 in pkg/neotest/client.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/client.go#L67

Added line #L67 was not covered by tests
}

ic.VM.LoadWithFlags(tx.Script, callflag.All)
err = ic.VM.Run()
return ic.VM.Estack(), err
Expand All @@ -78,6 +82,10 @@ func (c *ContractInvoker) TestInvoke(t testing.TB, method string, args ...any) (
}
t.Cleanup(ic.Finalize)

if c.collectCoverage {
ic.VM.SetOnExecHook(coverageHook)

Check warning on line 86 in pkg/neotest/client.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/client.go#L86

Added line #L86 was not covered by tests
}

ic.VM.LoadWithFlags(tx.Script, callflag.All)
err = ic.VM.Run()
return ic.VM.Estack(), err
Expand Down
21 changes: 12 additions & 9 deletions pkg/neotest/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import (

// Contract contains contract info for deployment.
type Contract struct {
Hash util.Uint160
NEF *nef.File
Manifest *manifest.Manifest
Hash util.Uint160
NEF *nef.File
Manifest *manifest.Manifest
DebugInfo *compiler.DebugInfo
}

// contracts caches the compiled contracts from FS across multiple tests.
Expand All @@ -36,9 +37,10 @@ func CompileSource(t testing.TB, sender util.Uint160, src io.Reader, opts *compi
require.NoError(t, err)

return &Contract{
Hash: state.CreateContractHash(sender, ne.Checksum, m.Name),
NEF: ne,
Manifest: m,
Hash: state.CreateContractHash(sender, ne.Checksum, m.Name),
NEF: ne,
Manifest: m,
DebugInfo: di,
}
}

Expand Down Expand Up @@ -73,9 +75,10 @@ func CompileFile(t testing.TB, sender util.Uint160, srcPath string, configPath s
require.NoError(t, err)

c := &Contract{
Hash: state.CreateContractHash(sender, ne.Checksum, m.Name),
NEF: ne,
Manifest: m,
Hash: state.CreateContractHash(sender, ne.Checksum, m.Name),
NEF: ne,
Manifest: m,
DebugInfo: di,
}
contracts[srcPath] = c
return c
Expand Down
205 changes: 205 additions & 0 deletions pkg/neotest/coverage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package neotest

import (
"flag"
"fmt"
"io"
"os"
"strconv"
"sync"
"testing"

"github.com/nspcc-dev/neo-go/pkg/compiler"
"github.com/nspcc-dev/neo-go/pkg/util"
"github.com/nspcc-dev/neo-go/pkg/vm"
"github.com/nspcc-dev/neo-go/pkg/vm/opcode"
)

const (
goCoverProfileFlag = "test.coverprofile"

Check failure on line 19 in pkg/neotest/coverage.go

View workflow job for this annotation

GitHub Actions / Lint

File is not `gofmt`-ed with `-s` (gofmt)
// disableNeotestCover is name of the environmental variable used to explicitly disable neotest coverage.
disableNeotestCover = "DISABLE_NEOTEST_COVER"
)

var (
// coverageLock protects all vars below from concurrent modification when tests are run in parallel.
coverageLock sync.Mutex
// rawCoverage maps script hash to coverage data, collected during testing.
rawCoverage = make(map[util.Uint160]*scriptRawCoverage)
// flagChecked is true if `go test` coverage flag was checked at any point.
flagChecked bool
// coverageEnabled is true if coverage is being collected on test execution.
coverageEnabled bool
// coverProfile specifies the file all coverage data is written to, unless empty.
coverProfile = ""
)

type scriptRawCoverage struct {
debugInfo *compiler.DebugInfo
offsetsVisited []int
}

type coverBlock struct {
// Line number for block start.
startLine uint
// Column number for block start.
startCol uint
// Line number for block end.
endLine uint
// Column number for block end.
endCol uint
// Number of statements included in this block.
stmts uint
// Number of times this block was executed.
counts uint
}

// documentName makes it clear when a `string` maps path to the script file.
type documentName = string

func isCoverageEnabled() bool {
coverageLock.Lock()
defer coverageLock.Unlock()

if flagChecked {
return coverageEnabled
}
defer func() { flagChecked = true }()

if isDisabledStr, ok := os.LookupEnv(disableNeotestCover); ok {
isDisabled, err := strconv.ParseBool(isDisabledStr)
if err != nil {
panic(fmt.Sprintf("coverage: error when parsing environment variable '%s', expected bool, but got '%s'", disableNeotestCover, isDisabledStr))

Check warning on line 72 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L72

Added line #L72 was not covered by tests
}
coverageEnabled = !isDisabled
return coverageEnabled
}

flag.VisitAll(func(f *flag.Flag) {
if f.Name == goCoverProfileFlag && f.Value != nil && f.Value.String() != "" {
coverageEnabled = true
coverProfile = f.Value.String()

Check warning on line 81 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L78-L81

Added lines #L78 - L81 were not covered by tests
}
})

if coverageEnabled {

Check warning on line 85 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L85

Added line #L85 was not covered by tests
// This is needed so go cover tool doesn't overwrite
// the file with our coverage when all tests are done.
err := flag.Set(goCoverProfileFlag, "")
if err != nil {
panic(err)

Check warning on line 90 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L88-L90

Added lines #L88 - L90 were not covered by tests
}
}
return coverageEnabled

Check warning on line 93 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L93

Added line #L93 was not covered by tests
}

var coverageHook vm.OnExecHook = func(scriptHash util.Uint160, offset int, opcode opcode.Opcode) {
coverageLock.Lock()
defer coverageLock.Unlock()
if cov, ok := rawCoverage[scriptHash]; ok {
cov.offsetsVisited = append(cov.offsetsVisited, offset)

Check warning on line 100 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L96-L100

Added lines #L96 - L100 were not covered by tests
}
}

func reportCoverage(t testing.TB) {
coverageLock.Lock()
defer coverageLock.Unlock()
f, err := os.Create(coverProfile)
if err != nil {
t.Fatalf("coverage: can't create file '%s' to write coverage report", coverProfile)

Check warning on line 109 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L104-L109

Added lines #L104 - L109 were not covered by tests
}
defer f.Close()
writeCoverageReport(f)

Check warning on line 112 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L111-L112

Added lines #L111 - L112 were not covered by tests
}

func writeCoverageReport(w io.Writer) {
fmt.Fprintf(w, "mode: set\n")
cover := processCover()
for name, blocks := range cover {
for _, b := range blocks {
c := 0
if b.counts > 0 {
c = 1

Check warning on line 122 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L115-L122

Added lines #L115 - L122 were not covered by tests
}
fmt.Fprintf(w, "%s:%d.%d,%d.%d %d %d\n", name,
b.startLine, b.startCol,
b.endLine, b.endCol,
b.stmts,
c,
)

Check warning on line 129 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L124-L129

Added lines #L124 - L129 were not covered by tests
}
}
}

func processCover() map[documentName][]coverBlock {
documents := make(map[documentName]struct{})
for _, scriptRawCoverage := range rawCoverage {
for _, documentName := range scriptRawCoverage.debugInfo.Documents {
documents[documentName] = struct{}{}

Check warning on line 138 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L134-L138

Added lines #L134 - L138 were not covered by tests
}
}

cover := make(map[documentName][]coverBlock)

Check warning on line 142 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L142

Added line #L142 was not covered by tests

for documentName := range documents {
mappedBlocks := make(map[int]*coverBlock)

Check warning on line 145 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L144-L145

Added lines #L144 - L145 were not covered by tests

for _, scriptRawCoverage := range rawCoverage {
di := scriptRawCoverage.debugInfo
documentSeqPoints := documentSeqPoints(di, documentName)

Check warning on line 149 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L147-L149

Added lines #L147 - L149 were not covered by tests

for _, point := range documentSeqPoints {
b := coverBlock{
startLine: uint(point.StartLine),
startCol: uint(point.StartCol),
endLine: uint(point.EndLine),
endCol: uint(point.EndCol),
stmts: 1 + uint(point.EndLine) - uint(point.StartLine),
counts: 0,

Check warning on line 158 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L151-L158

Added lines #L151 - L158 were not covered by tests
}
mappedBlocks[point.Opcode] = &b

Check warning on line 160 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L160

Added line #L160 was not covered by tests
}
}

for _, scriptRawCoverage := range rawCoverage {
di := scriptRawCoverage.debugInfo
documentSeqPoints := documentSeqPoints(di, documentName)

Check warning on line 166 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L164-L166

Added lines #L164 - L166 were not covered by tests

for _, offset := range scriptRawCoverage.offsetsVisited {
for _, point := range documentSeqPoints {
if point.Opcode == offset {
mappedBlocks[point.Opcode].counts++

Check warning on line 171 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L168-L171

Added lines #L168 - L171 were not covered by tests
}
}
}
}

var blocks []coverBlock
for _, b := range mappedBlocks {
blocks = append(blocks, *b)

Check warning on line 179 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L177-L179

Added lines #L177 - L179 were not covered by tests
}
cover[documentName] = blocks

Check warning on line 181 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L181

Added line #L181 was not covered by tests
}

return cover

Check warning on line 184 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L184

Added line #L184 was not covered by tests
}

func documentSeqPoints(di *compiler.DebugInfo, doc documentName) []compiler.DebugSeqPoint {
var res []compiler.DebugSeqPoint
for _, methodDebugInfo := range di.Methods {
for _, p := range methodDebugInfo.SeqPoints {
if di.Documents[p.Document] == doc {
res = append(res, p)

Check warning on line 192 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L187-L192

Added lines #L187 - L192 were not covered by tests
}
}
}
return res

Check warning on line 196 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L196

Added line #L196 was not covered by tests
}

func addScriptToCoverage(c *Contract) {
coverageLock.Lock()
defer coverageLock.Unlock()
if _, ok := rawCoverage[c.Hash]; !ok {
rawCoverage[c.Hash] = &scriptRawCoverage{debugInfo: c.DebugInfo}

Check warning on line 203 in pkg/neotest/coverage.go

View check run for this annotation

Codecov / codecov/patch

pkg/neotest/coverage.go#L199-L203

Added lines #L199 - L203 were not covered by tests
}
}
7 changes: 7 additions & 0 deletions pkg/neotest/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,12 @@ them in the same package with the smart contract iself can lead to unxpected
results if smart contract has any init() functions. If that's the case they
will be compiled into the testing binary even when using package_test and their
execution can affect tests. See https://github.com/nspcc-dev/neo-go/issues/3120 for details.
Test coverage for contracts is automatically enabled when `go test` is running with
coverage enabled. When not desired, it can be disabled for any Executor by using
EnableCoverage and DisableCoverage. Be aware that coverage data collected by `go test`
itself will not be saved because it will be replaced with contracts coverage instead.
Coverage is gathered by capturing VM instructions during test contract execution and
mapping them to the contract source code using the DebugInfo information.
*/
package neotest

0 comments on commit 5a1dd04

Please sign in to comment.