Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial coverage support to neotest #3462

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

Slava0135
Copy link
Contributor

@Slava0135 Slava0135 commented May 24, 2024

Requires #3460

Problem

Neotest doesn't provide coverage collection support when running contract tests

Solution

Executed opcodes collected by using OnExecHook for each script.
Then, using DebugSeqPoints, coverage for source file is evaluated and saved in file after each test.

Current implementation has some quirks with it:

  • File is written during each test cleanup, which can be heavy / slow down test execution significantly
  • In order to save coverage, coverProfile flag is reset, so go tooling doesn't overwrite coverage file. This can be a problem when running both contract and non-contract tests in the same project.
  • Scripts from dependencies are added to coverage file too, which is overkill.
  • Only set mode is supported.

@Slava0135 Slava0135 marked this pull request as draft May 24, 2024 08:52
@Slava0135
Copy link
Contributor Author

Slava0135 commented May 25, 2024

Showcase (using https://github.com/nspcc-dev/neofs-contract)

coverage3.mp4

It seems like some coverage data is lost when running all package tests at once, need more testing...

@Slava0135
Copy link
Contributor Author

Forgot to add reporting if script was already compiled. It does fix problem in the test file on video, but for some reason in other files almost all coverage data is lost.

@Slava0135
Copy link
Contributor Author

Fixed the issue.

Also the question is, where do we need add the coverage instrumentation? Currently, its only in TestInvoke() method, but there are other methods where VM is run.

pkg/vm/vm.go Outdated Show resolved Hide resolved
pkg/vm/vm.go Outdated Show resolved Hide resolved
pkg/neotest/coverage.go Outdated Show resolved Hide resolved
pkg/neotest/coverage.go Outdated Show resolved Hide resolved

func coverageHook() vm.OnExecHook {
return func(sh scriptHash, offset int, opcode opcode.Opcode) {
if cov, ok := rawCoverage[sh]; ok {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unprotected access to globals prevents us from executing tests in parallel. What about having a mutex (or sync.Map)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we just need to use normal Mutex, because there is no atomic "load and update" operation on sync.Map. If multiple goroutines read the map at the same time then append will be called from different goroutines too and only 1 value will be added likely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth to update the implementation, it's a frequent path.

return true
}
const coverProfileFlag = "test.coverprofile"
flag.VisitAll(func(f *flag.Flag) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name looks like it doesn't have any side-effects. What about doing this flag.VisitAll in init(). This way it will run once and set enabled variable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flags are defined and parsed when main is run - after init blocks, so you can't use init() here.
Maybe we should just change name to something more clear.
image

if enabled {
// this is needed so go cover tool doesn't overwrite
// the file with our coverage when all tests are done
flag.Set(coverProfileFlag, "")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite a hack and it could violate some go test internal invariants, in theory.
Could you describe other solutions you tried?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory yes, but in practice as of Go 1.20 coverProfile is used before all tests are run (see image) and after all tests were run (to write coverage)
image

The other solution is writing go test wrapper that rewrites coverage report file after it finishes, but this would require more effort from user.

pkg/neotest/coverage.go Outdated Show resolved Hide resolved
pkg/neotest/coverage.go Outdated Show resolved Hide resolved
@@ -401,6 +401,10 @@ func TestInvoke(bc *core.Blockchain, tx *transaction.Transaction) (*vm.VM, error
ttx := *tx
ic, _ := bc.GetTestVM(trigger.Application, &ttx, b)

if isCoverageEnabled() {
ic.VM.SetOnExecHook(coverageHook())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Speaking of parallel execution: the whole coverage* functions operate on global variables.
It seems we can create some CoverageContext here, provide its method to the VM and then process the results in defer. This way, our only conflict between different goroutines will be write to file.

Copy link
Contributor Author

@Slava0135 Slava0135 Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or we can add mutex that locks when the file is being written, or vm executes next instruction. The major concern is speed, but currently the bottleneck is actually processing coverage and writing to file (during each test cleanup).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me the approach with context is slightly better because it's more verbose (or explicit).

@Slava0135
Copy link
Contributor Author

rebased branch on master

@Slava0135 Slava0135 marked this pull request as ready for review July 3, 2024 10:54
@Slava0135
Copy link
Contributor Author

Slava0135 commented Jul 3, 2024

It seems to work, but I found out that when running this test, coverage profile for ListContainerSizes will be empty, even though it was clearly run inside the test.

Need to write unit tests for this feature somehow, to make sure it works as expected.

Copy link
Member

@AnnaShaleva AnnaShaleva left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, consider reformatting commit structure according to the https://github.com/nspcc-dev/.github/blob/master/git.md#logical-separation.

A really nice feature.

@@ -401,6 +401,10 @@ func TestInvoke(bc *core.Blockchain, tx *transaction.Transaction) (*vm.VM, error
ttx := *tx
ic, _ := bc.GetTestVM(trigger.Application, &ttx, b)

if isCoverageEnabled() {
ic.VM.SetOnExecHook(coverageHook())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me the approach with context is slightly better because it's more verbose (or explicit).

}

// CompileFile compiles a contract from the file and returns its NEF, manifest and hash.
func CompileFile(t testing.TB, sender util.Uint160, srcPath string, configPath string) *Contract {
if c, ok := contracts[srcPath]; ok {
collectCoverage(t, rawCoverage[c.Hash].debugInfo, c.Hash)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered moving collectCoverage to a separate exported function? If we extend neotest.Contract with DebugInfo field, then it will be accessible from neotest.CollectCoverage and users will be able to call it explicitly for those tests where it's needed (after the call to CompileFile, for example).

Comment on lines +66 to +70
func reportCoverage() {
f, err := os.Create(coverProfile)
if err != nil {
panic(fmt.Sprintf("coverage: can't create file '%s' to write coverage report", coverProfile))
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors may be handled more graceful if we introduce dependency on *testing.T.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although by the moment error occurs the *testing.T instance is likely to be finished already.

Comment on lines +58 to +64
func coverageHook() vm.OnExecHook {
return func(scriptHash util.Uint160, offset int, opcode opcode.Opcode) {
if cov, ok := rawCoverage[scriptHash]; ok {
cov.offsetsVisited = append(cov.offsetsVisited, offset)
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain why coverageHook is a function? Can't we have a global variable that contains an instance of vm.OnExecHook? It's read-only anyway, no one modifies it.

}

// CompileFile compiles a contract from the file and returns its NEF, manifest and hash.
func CompileFile(t testing.TB, sender util.Uint160, srcPath string, configPath string) *Contract {
if c, ok := contracts[srcPath]; ok {
collectCoverage(t, rawCoverage[c.Hash].debugInfo, c.Hash)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a documentation for it, please, consider adding a section to https://github.com/nspcc-dev/neo-go/blob/master/pkg/neotest/doc.go.

counts uint
}

type documentName = string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deserves a comment.

@AnnaShaleva
Copy link
Member

Need to write unit tests for this feature somehow

That would be nice.

Copy link

codecov bot commented Jul 3, 2024

Codecov Report

Attention: Patch coverage is 0% with 90 lines in your changes missing coverage. Please review.

Project coverage is 73.92%. Comparing base (6f77195) to head (917d4a2).

Files Patch % Lines
pkg/neotest/coverage.go 0.00% 76 Missing ⚠️
pkg/neotest/compile.go 0.00% 12 Missing ⚠️
pkg/neotest/basic.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #3462       +/-   ##
===========================================
- Coverage   86.11%   73.92%   -12.19%     
===========================================
  Files         331      332        +1     
  Lines       38564    38653       +89     
===========================================
- Hits        33209    28576     -4633     
- Misses       3824     8508     +4684     
- Partials     1531     1569       +38     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@Slava0135
Copy link
Contributor Author

Slava0135 commented Jul 10, 2024

Before I fix anything, we need to agree on some details:

  1. Collect coverage explicitly (context) or implicitly (when compiling), I picked second option because I though people wouldn't bother to enable coverage manually for every test. However, when writing tests for contracts, you have to do quite a lot of setup already.
  2. Enable coverage using external tool (sh + bat / python?) or using go test cleanup + unsetting flag, I picked second option because it requires no effort from user, but this relies on some assumptions about Go test infrastructure that may be changed in future. For first option you would need to put custom PATH for go when creating project. VSCode will probably handle it fine, not sure about GoLand and vim/neovim etc.

@AnnaShaleva
Copy link
Member

AnnaShaleva commented Jul 10, 2024

  1. Collect coverage explicitly (context) or implicitly (when compiling)

As I said in review, to me the first option is more preferable because it's explicit, i.e. allows users of neotest to pick those tests that will affect coverage. I think that sometimes there might be a situation when you need to exclude some test from the coverage if this test is designated for some other purpose. For example, our TestCreateBasicChain should not affect the coverage of our test contracts. Also, with this approach an explicit context is passed to the user of neotest so that it's clear that coverage is being collected (or not) for this test. And finally, implicit coverage collection bothers compilation step, so this step is actually not a compilation step anymore. But I'd like to hear @fyfyrchik and @roman-khimov opinion on this topic.

  1. Enable coverage using external tool (sh + bat / python?) or using go test cleanup + unsetting flag

To me the second approach also looks better because cleanups are native for Go, it's easy-to-go way, and I don't feel negative about the unsetting flag "hack".

but this relies on some assumptions about Go test infrastructure that may be changed in future

I think we'll be able to modify coverage module accordingly if something is changed. And for now this approach works fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants