-
Notifications
You must be signed in to change notification settings - Fork 77
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
base: master
Are you sure you want to change the base?
Conversation
Showcase (using https://github.com/nspcc-dev/neofs-contract) coverage3.mp4It seems like some coverage data is lost when running all package tests at once, need more testing... |
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. |
Fixed the issue. Also the question is, where do we need add the coverage instrumentation? Currently, its only in |
pkg/neotest/coverage.go
Outdated
|
||
func coverageHook() vm.OnExecHook { | ||
return func(sh scriptHash, offset int, opcode opcode.Opcode) { | ||
if cov, ok := rawCoverage[sh]; ok { |
There was a problem hiding this comment.
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
)?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pkg/neotest/coverage.go
Outdated
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, "") |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -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()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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).
This reverts commit 2b1ea89.
d3472d3
to
77173f5
Compare
rebased branch on master |
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. |
There was a problem hiding this 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()) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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).
func reportCoverage() { | ||
f, err := os.Create(coverProfile) | ||
if err != nil { | ||
panic(fmt.Sprintf("coverage: can't create file '%s' to write coverage report", coverProfile)) | ||
} |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
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) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deserves a comment.
That would be nice. |
Codecov ReportAttention: Patch coverage is
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. |
Before I fix anything, we need to agree on some details:
|
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.
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".
I think we'll be able to modify coverage module accordingly if something is changed. And for now this approach works fine. |
Requires #3460Problem
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:
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.set
mode is supported.