docs/TESTING.md (view raw)
1# Testing guide
2
3This document explains the testing strategy and test coverage for the LLM
4Aggregator project.
5
6## Running tests
7
8```bash
9# Run all tests
10go test ./...
11
12# Run tests with verbose output
13go test ./... -v
14
15# Run tests for a specific package
16go test ./internal/aggregator/...
17
18# Run tests with coverage
19go test ./... -cover
20```
21
22## Test packages
23
24### `internal/cli`: CLI parsing
25
26Tests for command-line argument handling using the `go-arg` library.
27
28| Test | Description |
29|------|-------------|
30| `TestFeedsFileParsing` | Verifies `--feeds-file` accepts valid file paths and rejects non-existent files |
31| `TestPromptParsing` | Tests `--prompt` handling including empty strings and special characters |
32| `TestAPIKeyParsing` | Verifies `--api-key` parsing and environment variable fallback |
33| `TestModelParsing` | Tests `--model` with default value (`deepseek-chat`) and custom models |
34| `TestBaseURLParsing` | Tests `--base-url` with various API endpoints |
35| `TestArgsToViperMap` | Ensures CLI arguments convert correctly to Viper configuration |
36| `TestParseKeywords` | Tests keyword parsing from comma-separated strings |
37
38Key features tested are argument validation and default value handling.
39
40---
41
42### `internal/config`: Configuration management
43
44Tests for the Viper-based configuration system.
45
46| Test | Description |
47|------|-------------|
48| `TestConfigParsingAlwaysPasses` | Ensures CLI argument parsing succeeds for valid inputs |
49| `TestConfigLoadWithVariousSources` | Tests loading from config files and environment variables |
50| `TestViperToConfigConversion` | Verifies conversion from Viper to Config struct |
51| `TestBindCLIArgs` | Tests binding CLI arguments to Viper |
52| `TestDefaultConfig` | Verifies default configuration values |
53| `TestConfigPath` | Tests XDG-compliant config path resolution |
54| `TestConfigSaveAndLoad` | Tests config file save/load round-trip |
55| `TestConfigExists` | Tests config file existence checking |
56| `TestEnvironmentVariables` | Tests environment variable loading |
57| `TestIsZero` | Tests `isZero()` helper with all types including pointer types |
58| `TestViperToRuntimePrecedence` | Tests configuration precedence: CLI > env vars > config file > defaults |
59| `TestBindCLIArgsWithPointers` | Tests that nil/zero CLI args don't override existing values |
60
61**Configuration precedence** (highest to lowest):
621. CLI arguments
632. Environment variables (`LLM_AGGREGATOR_*`)
643. Config file (`~/.config/llm_aggregator/config.toml`)
654. Default values
66
67> [!IMPORTANT] CLI arguments only override config file values when explicitly
68> provided. If `--model` is not passed on the command line, the config file or
69> environment variable value is used. This prevents CLI defaults from
70> overriding configuration file values.
71
72---
73
74### `internal/defaults`: Default values
75
76Tests for central default constants.
77
78| Test | Description |
79|------|-------------|
80| `TestDefaultValues` | Verifies all default constants are set correctly |
81| `TestDefaultSystemPrompt` | Tests the default summarisation prompt |
82| `TestDefaultValuesAreSensible` | Validates ranges (e.g., temperature 0-2, max articles > 0) |
83| `TestDefaultBaseURLIsValid` | Ensures URL format is valid |
84| `TestOutputFormatChoices` | Verifies supported output formats |
85
86**Default Values:**
87| Setting | Default | Description |
88|---------|---------|-------------|
89| `DefaultModel` | `deepseek-chat` | LLM model to use |
90| `DefaultBaseURL` | `https://api.deepseek.com` | API endpoint |
91| `DefaultMaxTokens` | `4000` | Maximum tokens per request |
92| `DefaultTemperature` | `0.7` | Sampling temperature (0-2) |
93| `DefaultMaxArticlesPerFeed` | `10` | Articles per feed |
94| `DefaultMaxDaysOld` | `7` | Maximum article age in days |
95| `DefaultMaxTotalArticles` | `20` | Total articles limit |
96| `DefaultOutput` | `text` | Output format |
97| `DefaultIncludeArticles` | `false` | Include articles in output |
98
99---
100
101### `internal/aggregator`: Feed aggregation
102
103Tests for feed parsing, article extraction, and filtering.
104
105| Test | Description |
106|------|-------------|
107| `TestNewFeedAggregator` | Tests aggregator creation with various configurations |
108| `TestNewFeedAggregatorWithProgress` | Tests with progress context |
109| `TestParseFeedsFromFile` | Verifies parsing feeds file with comments |
110| `TestParseFeedsFromFileEmpty` | Tests empty and comment-only files |
111| `TestParseFeedsFromFileNotFound` | Verifies error handling for missing files |
112| `TestArticleAgeFiltering` | Tests date-based article filtering |
113| `TestArticleSorting` | Tests date sorting (ascending/descending) |
114| `TestArticleLinkExtraction` | Verifies link extraction |
115| `TestArticleContentExtraction` | Tests content truncation |
116| `TestArticleToMap` | Tests article-to-map conversion |
117| `TestArticleStruct` | Tests Article struct methods |
118| `TestEmptyFeedsFile` | Tests handling of empty feeds file |
119
120**Mock data**
121
122```xml
123<?xml version="1.0" encoding="UTF-8"?>
124<rss version="2.0">
125 <channel>
126 <title>Test Feed</title>
127 <item>
128 <title>Article One</title>
129 <link>https://example.com/article1</link>
130 <pubDate>Wed, 15 Jan 2024 10:00:00 GMT</pubDate>
131 </item>
132 </channel>
133</rss>
134```
135
136---
137
138### `internal/output`: Summary formatting
139
140Tests for JSON, Markdown, and Text output formatting.
141
142| Test | Description |
143|------|-------------|
144| `TestNewFormatter` | Tests formatter creation with valid/invalid formats |
145| `TestFormatDataJSON` | Verifies JSON output structure |
146| `TestFormatDataMarkdown` | Tests Markdown formatting |
147| `TestFormatDataText` | Tests plain text formatting |
148| `TestFormatDataWithArticles` | Verifies articles appear in output |
149| `TestFormatDataMarkdownWithArticles` | Tests article formatting in Markdown |
150| `TestFormatDataDefaultValues` | Tests default values for missing fields |
151| `TestGetStringHelper` | Tests string extraction from map |
152| `TestGetIntHelper` | Tests integer extraction from map |
153| `TestCenterText` | Tests text centering |
154| `TestFormatArticleList` | Tests article list formatting |
155| `TestJSONIndentation` | Verifies proper JSON indentation |
156
157**Output Formats:**
158
159**JSON:**
160```json
161{
162 "title": "LLM Aggregator Summary",
163 "prompt": "Summarise the news",
164 "model": "deepseek-chat",
165 "articles_count": 5,
166 "timestamp": "2024-01-15T10:00:00Z",
167 "summary": "...",
168 "articles": [...]
169}
170```
171
172**Markdown:**
173```markdown
174# LLM Aggregator Summary
175
176## Metadata
177- **Prompt**: Summarise the news
178- **Model**: deepseek-chat
179- **Articles Analysed**: 5
180- **Generated**: 2024-01-15T10:00:00Z
181
182## Summary
183...
184
185## Articles Analysed
186### Article 1: Title
187**Source**: News Feed
188**Link**: [URL](URL)
189```
190
191**Text:**
192```
193================================================================
194 Title
195================================================================
196METADATA
197----------------------------------------
198Prompt: Summarise the news
199Model: deepseek-chat
200...
201================================================================
202 End of Summary
203================================================================
204```
205
206---
207
208### `internal/processor`: Content processing
209
210Tests for article filtering, sorting, and preparation for LLM summarisation.
211
212| Test | Description |
213|------|-------------|
214| `TestNewContentProcessor` | Tests processor creation |
215| `TestProcessArticles` | Tests overall article processing pipeline |
216| `TestFilterArticlesByIncludeKeywords` | Tests keyword inclusion filtering |
217| `TestFilterArticlesByExcludeKeywords` | Tests keyword exclusion filtering |
218| `TestFilterArticlesCaseInsensitive` | Verifies case-insensitive matching |
219| `TestSortArticlesByTitle` | Tests title sorting (A-Z, Z-A) |
220| `TestSortArticlesBySource` | Tests feed name sorting |
221| `TestPrepareForLLM` | Verifies article preparation for LLM |
222| `TestContentTruncation` | Tests content truncation |
223| `TestSetLogger` | Tests logger configuration |
224| `TestUnknownSortField` | Tests default sorting for unknown fields |
225| `TestMixedIncludeExclude` | Tests combined include/exclude filtering |
226| `TestEmptyKeywordLists` | Tests handling of empty keyword lists |
227
228**Filtering Logic:**
229
230```
231Original Articles
232 ↓
233┌─────────────────────────────┐
234│ 1. Check exclude keywords │
235│ - If match → EXCLUDE │
236│ - If no match → NEXT │
237└─────────────────────────────┘
238 ↓
239┌─────────────────────────────┐
240│ 2. Check include keywords │
241│ - If no filter → INCLUDE│
242│ - If match → INCLUDE │
243│ - If no match → EXCLUDE │
244└─────────────────────────────┘
245 ↓
246Filtered Articles
247 ↓
248┌─────────────────────────────┐
249│ 3. Sort (date/title/source) │
250└─────────────────────────────┘
251 ↓
252┌─────────────────────────────┐
253│ 4. Limit to maxTotalArticles│
254└─────────────────────────────┘
255 ↓
256┌─────────────────────────────┐
257│ 5. Truncate content │
258└─────────────────────────────┘
259 ↓
260Prepared Articles
261```
262
263**Sorting Options:**
264| Value | Behaviour |
265|-------|----------|
266| `date` | Sort by publication date (default) |
267| `title` | Sort alphabetically by title |
268| `source` | Sort by feed name |
269
270Keyword matching is case-insensitive.
271
272---
273
274## Test utilities
275
276### Helper functions
277
278| Package | Function | Purpose |
279|---------|----------|---------|
280| `aggregator` | `createTestArticles()` | Creates sample articles with dates |
281| `aggregator` | `writeTempFile()` | Creates temporary test files |
282| `processor` | `containsString()` | Checks if string exists in slice |
283| `output` | `getString()` | Extracts string from map with default |
284| `output` | `getInt()` | Extracts int from map with default |
285| `output` | `centerText()` | Centres text within width |
286
287### Mock data
288
289| Package | Data | Purpose |
290|---------|------|---------|
291| `aggregator` | `validRSSFeed` | Valid RSS XML for parsing tests |
292| `aggregator` | `atomFeed` | Atom XML for compatibility tests |
293| `aggregator` | `malformedRSSFeed` | Malformed XML for error handling |
294
295---
296
297## Coverage goals
298
299Current coverage status:
300
301| Package | Coverage | Notes |
302|---------|----------|-------|
303| `cli` | High | All argument types tested |
304| `config` | High | All config operations tested |
305| `defaults` | High | All defaults tested |
306| `aggregator` | Medium | Network calls require mocks |
307| `output` | High | All formatters tested |
308| `processor` | High | All filtering/sorting tested |
309| `llm` | Low | Requires API mocking |
310| `runtime` | Low | Integration tests only |
311| `tui` | None | Requires terminal interaction |
312
313---
314
315## Writing new tests
316
317### Test naming convention
318
319```go
320func Test<Component>_<Action>(t *testing.T) { ... }
321```
322
323Examples:
324- `TestNewFeedAggregator`: Test constructor
325- `TestFilterArticlesByExcludeKeywords`: Test filter with exclude
326- `TestFormatDataJSON`: Test JSON formatting
327
328### Table-driven tests
329
330Prefer table-driven tests for multiple test cases:
331
332```go
333func TestExample(t *testing.T) {
334 tests := []struct {
335 name string
336 input string
337 expected string
338 }{
339 {"empty string", "", ""},
340 {"single word", "hello", "hello"},
341 {"multiple words", "hello world", "hello world"},
342 }
343
344 for _, tt := range tests {
345 t.Run(tt.name, func(t *testing.T) {
346 result := Function(tt.input)
347 if result != tt.expected {
348 t.Errorf("got %q, want %q", result, tt.expected)
349 }
350 })
351 }
352}
353```
354
355### Using temporary files
356
357```go
358func TestWithTempFile(t *testing.T) {
359 tmpDir := t.TempDir() // Go 1.15+
360 tmpFile := tmpDir + "/test.txt"
361
362 if err := os.WriteFile(tmpFile, []byte("content"), 0644); err != nil {
363 t.Fatalf("Failed to create temp file: %v", err)
364 }
365
366 // Test using tmpFile
367}
368```
369
370### Testing `nil` safety
371
372Always test `nil`/empty cases:
373
374```go
375t.Run("nil input", func(t *testing.T) {
376 result := ProcessArticles(nil, "date", false)
377 if len(result) != 0 {
378 t.Error("Expected empty result for nil input")
379 }
380})
381
382t.Run("empty slice", func(t *testing.T) {
383 result := ProcessArticles([]*Article{}, "date", false)
384 if len(result) != 0 {
385 t.Error("Expected empty result for empty slice")
386 }
387})
388```
389
390---
391
392## Continuous integration
393
394Tests should pass before committing:
395
396```bash
397# Verify all tests pass
398go test ./...
399
400# Verify with race detector
401go test ./... -race
402
403# Verify with coverage
404go test ./... -coverprofile=coverage.out
405go tool cover -html=coverage.out
406```