-
Notifications
You must be signed in to change notification settings - Fork 445
Tighten anomaly detector test coverage and subtest structure #43364
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
Changes from 5 commits
b2555d6
8d50d05
3dccf72
5743392
c515a68
d186359
898e438
a5037b2
25325e1
24957b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,17 @@ import ( | |
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func assertAnomalyScore(t *testing.T, want, got float64) { | ||
| t.Helper() | ||
|
|
||
| if want == 0 { | ||
| assert.Zero(t, got, "AnomalyScore mismatch") | ||
| return | ||
| } | ||
|
|
||
| assert.InDelta(t, want, got, 1e-9, "AnomalyScore mismatch") | ||
| } | ||
|
|
||
| func TestAnomalyDetector_Analyze(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
|
|
@@ -227,12 +238,22 @@ func TestAnomalyDetector_Analyze(t *testing.T) { | |
| assert.Equal(t, tt.wantIsNewTemplate, report.IsNewTemplate, "IsNewTemplate mismatch") | ||
| assert.Equal(t, tt.wantLowSimilarity, report.LowSimilarity, "LowSimilarity mismatch") | ||
| assert.Equal(t, tt.wantRareCluster, report.RareCluster, "RareCluster mismatch") | ||
| assert.InDelta(t, tt.wantScore, report.AnomalyScore, 1e-9, "AnomalyScore mismatch") | ||
| assertAnomalyScore(t, tt.wantScore, report.AnomalyScore) | ||
| assert.Equal(t, tt.wantReason, report.Reason, "Reason mismatch") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestAnomalyDetector_Analyze_PanicsOnNilResultParameter(t *testing.T) { | ||
| detector, err := NewAnomalyDetector(0.4, 2) | ||
| require.NoError(t, err, "NewAnomalyDetector should succeed") | ||
| require.NotNil(t, detector, "NewAnomalyDetector should return a non-nil detector") | ||
|
|
||
| assert.Panics(t, func() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test correctly documents the current behavior, but it encodes a silent nil-dereference panic as "expected." The Consider one of:
LowSimilarity: !isNew && result != nil && result.Similarity < d.threshold,
As written the test normalises a latent API hazard in the production code without any comment in @copilot please address this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] π‘ SuggestionIf the goal is purely to document existing behavior, add an explicit comment: // This test pins the current panic; a follow-up should make Analyze return an
// error instead so callers can handle the failure gracefully.Or, open a companion issue and reference it here so the debt is trackable. @copilot please address this. |
||
| detector.Analyze(nil, false, &Cluster{ID: 1, Template: []string{"a"}, Size: 1}) | ||
| }, "Analyze should panic when result is nil for an existing template") | ||
|
|
||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Panic test codifies a nil-dereference bug as an intentional API contract. The underlying cause is an unconditional nil pointer dereference in π‘ Analysis and suggested fixThe production code panics here: LowSimilarity: !isNew && result.Similarity < d.threshold,When A test that asserts Option 1 β Fix the bug instead of testing it: func (d *AnomalyDetector) Analyze(result *MatchResult, isNew bool, cluster *Cluster) (*AnomalyReport, error) {
if !isNew && result == nil {
return nil, errors.New("agentdrain: Analyze: result must not be nil when isNew=false")
}
// ... rest of function
}Option 2 β If the panic is truly intentional, add a doc comment and rename the test: // TestAnalyzeEvent_NilResult_KnownPanic documents a known limitation: passing nil result
// with isNew=false causes a panic. TODO: replace panic with error return (#NNN).At minimum, a passing |
||
|
|
||
| func TestNewAnomalyDetector_ThresholdBoundaries(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
|
|
@@ -275,7 +296,7 @@ func TestNewAnomalyDetector_ThresholdBoundaries(t *testing.T) { | |
| detector, err := NewAnomalyDetector(tt.simThreshold, tt.rareThreshold) | ||
| if tt.wantErr != "" { | ||
| require.Error(t, err, "NewAnomalyDetector should reject invalid thresholds") | ||
| assert.Contains(t, err.Error(), tt.wantErr, "error should describe invalid threshold") | ||
| require.ErrorContains(t, err, tt.wantErr, "error should describe invalid threshold") | ||
| assert.Nil(t, detector, "NewAnomalyDetector should return nil detector on validation error") | ||
| return | ||
| } | ||
|
|
@@ -371,9 +392,6 @@ func TestBuildReason(t *testing.T) { | |
|
|
||
| func TestAnalyzeEvent(t *testing.T) { | ||
| cfg := DefaultConfig() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The shared The issue is that the However, the comment (or lack thereof) at the top of the table makes the // setupEvents seeds the miner before the test event so it sees a pre-warmed state.
// Every subtest gets a fresh miner; setup events are re-applied per case.This will prevent future maintainers from removing the per-subtest miner construction (and breaking isolation) thinking it is unnecessary. @copilot please address this. |
||
| m, err := NewMiner(cfg) | ||
| require.NoError(t, err, "NewMiner should succeed") | ||
| require.NotNil(t, m, "NewMiner should return a non-nil miner") | ||
|
|
||
| evtPlan := AgentEvent{ | ||
| Stage: "plan", | ||
|
|
@@ -384,32 +402,78 @@ func TestAnalyzeEvent(t *testing.T) { | |
| Fields: map[string]string{"status": "ok"}, | ||
| } | ||
|
|
||
| // Step 1: first occurrence trains the template and is flagged as new. | ||
| resultFirst, reportFirst, errFirst := m.AnalyzeEvent(evtPlan) | ||
| require.NoError(t, errFirst, "AnalyzeEvent should not fail for first event") | ||
| require.NotNil(t, resultFirst, "AnalyzeEvent should return a non-nil result") | ||
| require.NotNil(t, reportFirst, "AnalyzeEvent should return a non-nil report") | ||
| assert.True(t, reportFirst.IsNewTemplate, "IsNewTemplate mismatch for first event") | ||
| assert.InDelta(t, 0.65, reportFirst.AnomalyScore, 1e-9, "AnomalyScore mismatch for first event") | ||
| assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportFirst.Reason, "Reason mismatch for first event") | ||
| tests := []struct { | ||
| name string | ||
| setupEvents []AgentEvent | ||
| event AgentEvent | ||
| wantIsNew bool | ||
| wantScore float64 | ||
| wantReason string | ||
| }{ | ||
| { | ||
| name: "first occurrence trains a new template", | ||
| event: evtPlan, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Refactored table test drops π‘ Why this matters and how to fix itThe test verifies The existing Adding two fields and two assertions closes this gap with minimal effort: tests := []struct {
name string
setupEvents []AgentEvent
event AgentEvent
wantIsNew bool
wantLowSimilarity bool // add
wantRareCluster bool // add
wantScore float64
wantReason string
}{
{
name: "first occurrence trains a new template",
event: evtPlan,
wantIsNew: true,
wantLowSimilarity: false,
wantRareCluster: true,
wantScore: 0.65,
wantReason: "new log template discovered; rare cluster (few observations)",
},
// ...
}
// in the assertion block:
assert.Equal(t, tt.wantLowSimilarity, report.LowSimilarity, "LowSimilarity mismatch")
assert.Equal(t, tt.wantRareCluster, report.RareCluster, "RareCluster mismatch") |
||
| wantIsNew: true, | ||
| wantScore: 0.65, | ||
| wantReason: "new log template discovered; rare cluster (few observations)", | ||
| }, | ||
| { | ||
| name: "second identical occurrence reuses the template", | ||
| setupEvents: []AgentEvent{evtPlan}, | ||
| event: evtPlan, | ||
| wantIsNew: false, | ||
| wantScore: 0.15, | ||
| wantReason: "rare cluster (few observations)", | ||
| }, | ||
| { | ||
| name: "distinct event creates a separate template", | ||
| setupEvents: []AgentEvent{evtPlan}, | ||
| event: evtFinish, | ||
| wantIsNew: true, | ||
| wantScore: 0.65, | ||
| wantReason: "new log template discovered; rare cluster (few observations)", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The new table-driven subtests are a clear improvement in isolation, but none of them call π‘ SuggestionEither add for _, tt := range tests {
tt := tt // pin for parallel safety (pre-Go 1.22)
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}If intentionally sequential (e.g., shared miner state), add a comment explaining why. @copilot please address this. |
||
| m, err := NewMiner(cfg) | ||
| require.NoError(t, err, "NewMiner should succeed") | ||
| require.NotNil(t, m, "NewMiner should return a non-nil miner") | ||
|
|
||
| for _, setupEvent := range tt.setupEvents { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The setup loop silently discards the result values; if a setup π‘ SuggestionInclude an index or event identifier in the error message so failures are easier to diagnose: for i, setupEvent := range tt.setupEvents {
_, _, err := m.AnalyzeEvent(setupEvent)
require.NoError(t, err, "AnalyzeEvent setup[%d] should not fail", i)
}@copilot please address this. |
||
| _, _, err := m.AnalyzeEvent(setupEvent) | ||
| require.NoError(t, err, "AnalyzeEvent setup should not fail") | ||
| } | ||
|
|
||
| result, report, err := m.AnalyzeEvent(tt.event) | ||
| require.NoError(t, err, "AnalyzeEvent should not fail") | ||
| require.NotNil(t, result, "AnalyzeEvent should return a non-nil result") | ||
| require.NotNil(t, report, "AnalyzeEvent should return a non-nil report") | ||
| assert.Equal(t, tt.wantIsNew, report.IsNewTemplate, "IsNewTemplate mismatch") | ||
| assertAnomalyScore(t, tt.wantScore, report.AnomalyScore) | ||
| assert.Equal(t, tt.wantReason, report.Reason, "Reason mismatch") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestAnalyzeEvent_EmptyAfterMasking(t *testing.T) { | ||
| cfg := DefaultConfig() | ||
| cfg.MaskRules = []MaskRule{{ | ||
| Name: "eraseAllTokens", | ||
| Pattern: ".+", | ||
| Replacement: "", | ||
| }} | ||
|
|
||
| // Step 2: second identical occurrence reuses the trained template. | ||
| resultSecond, reportSecond, errSecond := m.AnalyzeEvent(evtPlan) | ||
| require.NoError(t, errSecond, "AnalyzeEvent should not fail for second identical event") | ||
| require.NotNil(t, resultSecond, "AnalyzeEvent should return a non-nil result") | ||
| require.NotNil(t, reportSecond, "AnalyzeEvent should return a non-nil report") | ||
| assert.False(t, reportSecond.IsNewTemplate, "IsNewTemplate mismatch for second identical event") | ||
| assert.InDelta(t, 0.15, reportSecond.AnomalyScore, 1e-9, "AnomalyScore mismatch for second identical event") | ||
| assert.Equal(t, "rare cluster (few observations)", reportSecond.Reason, "Reason mismatch for second identical event") | ||
| m, err := NewMiner(cfg) | ||
| require.NoError(t, err, "NewMiner should succeed") | ||
| require.NotNil(t, m, "NewMiner should return a non-nil miner") | ||
|
|
||
| // Step 3: a distinct event creates a separate new template. | ||
| resultDistinct, reportDistinct, errDistinct := m.AnalyzeEvent(evtFinish) | ||
| require.NoError(t, errDistinct, "AnalyzeEvent should not fail for distinct event") | ||
| require.NotNil(t, resultDistinct, "AnalyzeEvent should return a non-nil result") | ||
| require.NotNil(t, reportDistinct, "AnalyzeEvent should return a non-nil report") | ||
| assert.True(t, reportDistinct.IsNewTemplate, "IsNewTemplate mismatch for distinct event") | ||
| assert.InDelta(t, 0.65, reportDistinct.AnomalyScore, 1e-9, "AnomalyScore mismatch for distinct event") | ||
| assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportDistinct.Reason, "Reason mismatch for distinct event") | ||
| result, report, err := m.AnalyzeEvent(AgentEvent{Stage: "plan"}) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The masking test uses π‘ SuggestionAdd a brief inline comment to make the intent explicit: // No Fields needed: the mask rule ".+" erases the stage token too,
// leaving an empty string after masking.
result, report, err := m.AnalyzeEvent(AgentEvent{Stage: "plan"})Also consider adding a second case where @copilot please address this. |
||
| require.Error(t, err, "AnalyzeEvent should reject events that become empty after masking") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
π‘ Robustness concern
This is working now, but the test is fragile in two ways:
Consider using a mask rule that is explicitly designed to test the empty-after-masking path, and add a comment explaining why this rule is chosen: // Replace every non-whitespace character, leaving only spaces which Tokenize strips.
cfg.MaskRules = []MaskRule{{
Name: "eraseAllNonWhitespace",
Pattern: `\S+`,
Replacement: "",
}}Or alternatively, verify that the test input actually reaches the masking step by inspecting intermediate state, or adding a second case with a whitespace-only flattened event. |
||
| require.ErrorContains(t, err, "empty event after masking", "AnalyzeEvent should report the masking failure") | ||
| assert.Nil(t, result, "AnalyzeEvent should not return a result on masking failure") | ||
| assert.Nil(t, report, "AnalyzeEvent should not return a report on masking failure") | ||
| } | ||
|
|
||
| func TestAnalyze_FlagMutualExclusivity(t *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.
[/tdd] The helper signature
assertAnomalyScore(t, want, got)diverges from testify's own convention of(t, expected, actual)β but more practically, the argument order is the opposite of howassert.InDeltais called internally (assert.InDelta(t, want, got, ...)), which is correct. The concern is that callers reading the signature may accidentally swap want/got and get silent false-positives.π‘ Suggestion
Consider renaming the parameters to match testify convention (
expected,actual) or adding a brief doc comment:@copilot please address this.