Skip to content

Commit 0172f4e

Browse files
Setup unit tests (#5)
* Add comprehensive unit tests for percentile calculations This commit adds complete test coverage for the percentile calculation functions with support for both simple and linear interpolation algorithms. Changes: - Add unit tests for percentileN and percentileNLinearInterpolation functions - Add integration tests for the main percentile function - Add tests for invalid input handling - Add benchmark tests for performance measurement - Update .gitignore to exclude coverage.out Test coverage: 76.6% of statements All tests pass with race detection enabled 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add GitHub Actions workflow with pinned actions This commit adds a comprehensive CI/CD workflow for automated testing. Features: - Matrix build on Go 1.23 and 1.24 - Tests with race detection and coverage reporting - Benchmark tests - Code formatting checks (gofmt) - Static analysis (go vet) - Separate build job Security: - Actions pinned by commit SHA (not tags) for immutability - actions/checkout@11bd719 (v4.2.2) - actions/setup-go@3041bf5 (v5.2.0) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update workflow: rename to test, update branches format, and upgrade to latest actions Changes: - Rename workflow from "Tests" to "test" - Update branches format to multi-line YAML array style - Upgrade actions to latest releases with SHA pinning: - actions/checkout: v4.2.2 → v5.0.0 (08c6903cd8c0fde910a37f88322edcfb5dd907a8) - actions/setup-go: v5.2.0 → v6.0.0 (44694677b345b0fc1c561a53f573162bd1dd1749) Note: v5.0.0 of checkout and v6.0.0 of setup-go both require runner version v2.327.1+ and use Node 24 runtime. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove branches filter from pull_request trigger This allows the workflow to run on all pull requests, including multi-stage PRs (e.g., feature branch → staging → main). Previously, the workflow would only run on PRs targeting main, which could miss important validation for multi-stage workflows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix actions/setup-go v6.0.0 commit SHA Correct the commit SHA for actions/setup-go v6.0.0: - Incorrect: 44694677b345b0fc1c561a53f573162bd1dd1749 - Correct: 44694675825211faa026b3c33043df3e48a5fa00 This fixes the workflow execution failure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove Go 1.23 from test matrix go.mod requires go >= 1.24.7, so testing with Go 1.23 fails. Removed Go 1.23 from the matrix to only test with Go 1.24. This fixes the test error: go: go.mod requires go >= 1.24.7 (running go 1.23.12; GOTOOLCHAIN=local) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove matrix strategy from test job This is a CLI tool, not a library, so testing against multiple Go versions is unnecessary. Simplified to test only with Go 1.24. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix AppName to match repository name Changes: - Change AppName from "numstat" to "percentile" to match repository name - Update test to check AppName dynamically instead of hardcoded value This fixes the test failure in GitHub Actions where the output was "percentile v0.0.1" instead of the expected "numstat v0.0.1". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d908112 commit 0172f4e

3 files changed

Lines changed: 353 additions & 0 deletions

File tree

.github/workflows/test.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
name: Run Tests
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- name: Checkout code
19+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
20+
21+
- name: Set up Go
22+
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
23+
with:
24+
go-version: '1.24'
25+
check-latest: true
26+
27+
- name: Download dependencies
28+
run: go mod download
29+
30+
- name: Verify dependencies
31+
run: go mod verify
32+
33+
- name: Run tests
34+
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
35+
36+
- name: Run benchmarks
37+
run: go test -bench=. -benchmem ./...
38+
39+
- name: Check code formatting
40+
run: |
41+
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
42+
echo "The following files need to be formatted:"
43+
gofmt -s -l .
44+
exit 1
45+
fi
46+
47+
- name: Run go vet
48+
run: go vet ./...
49+
50+
build:
51+
name: Build
52+
runs-on: ubuntu-latest
53+
54+
steps:
55+
- name: Checkout code
56+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
57+
58+
- name: Set up Go
59+
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
60+
with:
61+
go-version: '1.24'
62+
check-latest: true
63+
64+
- name: Build
65+
run: go build -v ./...

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/build
22
/percentile
33
/pkg
4+
coverage.out

main_test.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"math"
7+
"sort"
8+
"strings"
9+
"testing"
10+
)
11+
12+
const float64EqualityThreshold = 1e-9
13+
14+
func almostEqual(a, b float64) bool {
15+
return math.Abs(a-b) <= float64EqualityThreshold
16+
}
17+
18+
func TestPercentileN(t *testing.T) {
19+
tests := []struct {
20+
name string
21+
numbers []float64
22+
percentile int
23+
expected float64
24+
}{
25+
{
26+
name: "simple dataset 50th percentile",
27+
numbers: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
28+
percentile: 50,
29+
expected: 5,
30+
},
31+
{
32+
name: "simple dataset 90th percentile",
33+
numbers: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
34+
percentile: 90,
35+
expected: 9,
36+
},
37+
{
38+
name: "simple dataset 100th percentile",
39+
numbers: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
40+
percentile: 100,
41+
expected: 10,
42+
},
43+
}
44+
45+
for _, tt := range tests {
46+
t.Run(tt.name, func(t *testing.T) {
47+
numbers := sort.Float64Slice(tt.numbers)
48+
sort.Sort(numbers)
49+
result := percentileN(&numbers, len(numbers), tt.percentile)
50+
if result != tt.expected {
51+
t.Errorf("percentileN() = %v, want %v", result, tt.expected)
52+
}
53+
})
54+
}
55+
}
56+
57+
func TestPercentileNLinearInterpolation(t *testing.T) {
58+
tests := []struct {
59+
name string
60+
numbers []float64
61+
percentile int
62+
expected float64
63+
}{
64+
{
65+
name: "simple dataset 50th percentile",
66+
numbers: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
67+
percentile: 50,
68+
expected: 5.5,
69+
},
70+
{
71+
name: "simple dataset 90th percentile",
72+
numbers: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
73+
percentile: 90,
74+
expected: 9.1,
75+
},
76+
{
77+
name: "simple dataset 95th percentile",
78+
numbers: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
79+
percentile: 95,
80+
expected: 9.55,
81+
},
82+
{
83+
name: "simple dataset 100th percentile",
84+
numbers: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
85+
percentile: 100,
86+
expected: 10,
87+
},
88+
{
89+
name: "empty dataset",
90+
numbers: []float64{},
91+
percentile: 50,
92+
expected: 0,
93+
},
94+
{
95+
name: "single element",
96+
numbers: []float64{5},
97+
percentile: 50,
98+
expected: 5,
99+
},
100+
{
101+
name: "two elements 50th percentile",
102+
numbers: []float64{1, 2},
103+
percentile: 50,
104+
expected: 1.5,
105+
},
106+
}
107+
108+
for _, tt := range tests {
109+
t.Run(tt.name, func(t *testing.T) {
110+
numbers := sort.Float64Slice(tt.numbers)
111+
sort.Sort(numbers)
112+
result := percentileNLinearInterpolation(&numbers, len(numbers), tt.percentile)
113+
if !almostEqual(result, tt.expected) {
114+
t.Errorf("percentileNLinearInterpolation() = %v, want %v", result, tt.expected)
115+
}
116+
})
117+
}
118+
}
119+
120+
func TestPercentileFunction(t *testing.T) {
121+
tests := []struct {
122+
name string
123+
input string
124+
opts Options
125+
expectedContains []string
126+
}{
127+
{
128+
name: "basic input with default algorithm",
129+
input: "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n",
130+
opts: Options{
131+
Algorithm: "linear-interpolation",
132+
},
133+
expectedContains: []string{"50%:", "5.5", "90%:", "9.1", "100%:", "10"},
134+
},
135+
{
136+
name: "basic input with simple algorithm",
137+
input: "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n",
138+
opts: Options{
139+
Algorithm: "simple",
140+
},
141+
expectedContains: []string{"50%:", "5", "90%:", "9", "100%:", "10"},
142+
},
143+
{
144+
name: "version flag",
145+
input: "",
146+
opts: Options{
147+
ShowVersion: true,
148+
},
149+
expectedContains: []string{AppName, "v0.0.1"},
150+
},
151+
}
152+
153+
for _, tt := range tests {
154+
t.Run(tt.name, func(t *testing.T) {
155+
// Reset global numbers slice for each test
156+
numbers = sort.Float64Slice{}
157+
158+
reader := strings.NewReader(tt.input)
159+
stdout := &bytes.Buffer{}
160+
stderr := &bytes.Buffer{}
161+
162+
err := percentile(reader, stdout, stderr, tt.opts)
163+
if err != nil {
164+
t.Fatalf("percentile() error = %v", err)
165+
}
166+
167+
output := stdout.String()
168+
for _, expected := range tt.expectedContains {
169+
if !strings.Contains(output, expected) {
170+
t.Errorf("percentile() output missing %q, got: %q", expected, output)
171+
}
172+
}
173+
})
174+
}
175+
}
176+
177+
func TestPercentileWithInvalidInput(t *testing.T) {
178+
// Reset global numbers slice
179+
numbers = sort.Float64Slice{}
180+
181+
input := "1\n2\ninvalid\n3\n4\n5\n"
182+
reader := strings.NewReader(input)
183+
stdout := &bytes.Buffer{}
184+
stderr := &bytes.Buffer{}
185+
186+
opts := Options{
187+
Algorithm: "linear-interpolation",
188+
}
189+
190+
err := percentile(reader, stdout, stderr, opts)
191+
if err != nil {
192+
t.Fatalf("percentile() error = %v", err)
193+
}
194+
195+
// Check that error message was written to stderr
196+
if !strings.Contains(stderr.String(), "number conversion error") {
197+
t.Errorf("Expected error message in stderr, got: %q", stderr.String())
198+
}
199+
200+
// Should still process valid numbers
201+
if stdout.Len() == 0 {
202+
t.Error("Expected output for valid numbers")
203+
}
204+
}
205+
206+
func TestPrintPercentileN(t *testing.T) {
207+
numbers := sort.Float64Slice([]float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
208+
sort.Sort(numbers)
209+
210+
tests := []struct {
211+
name string
212+
percentile int
213+
algorithm string
214+
expected string
215+
}{
216+
{
217+
name: "50th percentile with linear interpolation",
218+
percentile: 50,
219+
algorithm: "linear-interpolation",
220+
expected: "50%:\t5.5\n",
221+
},
222+
{
223+
name: "90th percentile with simple algorithm",
224+
percentile: 90,
225+
algorithm: "simple",
226+
expected: "90%:\t9\n",
227+
},
228+
{
229+
name: "default to linear interpolation",
230+
percentile: 75,
231+
algorithm: "unknown",
232+
expected: "75%:\t7.75\n",
233+
},
234+
}
235+
236+
for _, tt := range tests {
237+
t.Run(tt.name, func(t *testing.T) {
238+
var buf bytes.Buffer
239+
printPercentileN(&buf, &numbers, len(numbers), tt.percentile, tt.algorithm)
240+
241+
if buf.String() != tt.expected {
242+
t.Errorf("printPercentileN() = %q, want %q", buf.String(), tt.expected)
243+
}
244+
})
245+
}
246+
}
247+
248+
// Benchmark tests
249+
func BenchmarkPercentileNLinearInterpolation(b *testing.B) {
250+
numbers := make(sort.Float64Slice, 1000)
251+
for i := 0; i < 1000; i++ {
252+
numbers[i] = float64(i)
253+
}
254+
sort.Sort(numbers)
255+
256+
b.ResetTimer()
257+
for i := 0; i < b.N; i++ {
258+
percentileNLinearInterpolation(&numbers, len(numbers), 95)
259+
}
260+
}
261+
262+
func BenchmarkPercentileN(b *testing.B) {
263+
numbers := make(sort.Float64Slice, 1000)
264+
for i := 0; i < 1000; i++ {
265+
numbers[i] = float64(i)
266+
}
267+
sort.Sort(numbers)
268+
269+
b.ResetTimer()
270+
for i := 0; i < b.N; i++ {
271+
percentileN(&numbers, len(numbers), 95)
272+
}
273+
}
274+
275+
func BenchmarkPercentile(b *testing.B) {
276+
input := strings.Repeat("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", 100)
277+
opts := Options{
278+
Algorithm: "linear-interpolation",
279+
}
280+
281+
b.ResetTimer()
282+
for i := 0; i < b.N; i++ {
283+
numbers = sort.Float64Slice{}
284+
reader := strings.NewReader(input)
285+
percentile(reader, io.Discard, io.Discard, opts)
286+
}
287+
}

0 commit comments

Comments
 (0)