Skip to content

Commit 8c08282

Browse files
authored
test coverage (#2)
* test implementation in the weatherapi package * test workflow * update badges * test tools * use mockgen * separate tool and handler tests * verbose output in test workflow * clean up test suite * core application logic tests * cleanup and simplify setupRenderer * using io.Writer * Any instead of AssignableToTypeOf * generate mocks in github actions * using Makefile in test action * add testing guidelines to README * remove HTML rendering interface * mock external services
1 parent 487d1a9 commit 8c08282

File tree

16 files changed

+417
-12
lines changed

16 files changed

+417
-12
lines changed

.github/workflows/test.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
on: [push, pull_request]
2+
name: Test
3+
4+
jobs:
5+
test:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- name: Check out code
9+
uses: actions/checkout@v4
10+
11+
- name: Set up Go
12+
uses: actions/setup-go@v5
13+
with:
14+
go-version-file: "go.mod"
15+
16+
- name: Download dependencies
17+
run: go mod download
18+
19+
- name: Install mockgen
20+
run: go install go.uber.org/mock/mockgen@latest
21+
22+
- name: Generate mocks
23+
run: make generate-mocks
24+
25+
- name: Run tests
26+
run: make run-tests

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.PHONY: help
2+
help:
3+
@awk 'BEGIN {FS = ":.*##"; printf "Usage: make \033[36m<target>\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
4+
5+
.DEFAULT_GOAL := help
6+
7+
##@ Tests
8+
generate-mocks: ## generate mock files using mockgen
9+
go generate ./internal/server/services
10+
11+
run-tests: ## run unit tests
12+
go test -v -race ./...

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
[![License](https://img.shields.io/badge/license-MIT-red.svg)](LICENSE)
77
[![Go Version](https://img.shields.io/github/go-mod/go-version/TuanKiri/weather-mcp-server)](go.mod)
88
[![Go Report Card](https://goreportcard.com/badge/github.com/TuanKiri/weather-mcp-server?cache)](https://goreportcard.com/report/github.com/TuanKiri/weather-mcp-server)
9-
[![Build](https://github.com/TuanKiri/weather-mcp-server/workflows/Build/badge.svg)](https://github.com/TuanKiri/weather-mcp-server/actions?workflow=Build)
9+
[![Build](https://github.com/TuanKiri/weather-mcp-server/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/TuanKiri/weather-mcp-server/actions?workflow=Build)
10+
[![Tests](https://github.com/TuanKiri/weather-mcp-server/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/TuanKiri/weather-mcp-server/actions?workflow=Test)
1011

1112
<strong>[Report Bug](https://github.com/TuanKiri/weather-mcp-server/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml)</strong> | <strong>[Request Feature](https://github.com/TuanKiri/weather-mcp-server/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml)</strong>
1213

@@ -29,7 +30,6 @@ To use your MCP server with Claude Desktop, add it to your Claude configuration:
2930
"mcpServers": {
3031
"weather-mcp-server": {
3132
"command": "/path/to/weather-mcp-server",
32-
"args": [],
3333
"env": {
3434
"WEATHER_API_KEY": "your-api-key"
3535
}
@@ -62,7 +62,7 @@ go build -o weather-mcp-server ./cmd/weather-mcp-server
6262

6363
## Using MCP with Docker Containers
6464

65-
#### 1. Build the Docker image:
65+
#### 1. Build the Docker Image:
6666

6767
```shell
6868
docker build -t weather-mcp-server .
@@ -100,6 +100,30 @@ The project is organized into several key directories:
100100
└── pkg
101101
```
102102

103+
## Testing
104+
105+
If you're adding new features, please make sure to include tests for them.
106+
107+
#### 1. Install the mockgen tool:
108+
109+
```shell
110+
go install go.uber.org/mock/mockgen@latest
111+
```
112+
113+
See the installation guide on [go.uber.org/mock](https://github.com/uber-go/mock?tab=readme-ov-file#installation).
114+
115+
#### 2. Use the following command to generate mock files:
116+
117+
```shell
118+
make generate-mocks
119+
```
120+
121+
#### 3. To run unit tests:
122+
123+
```shell
124+
make run-tests
125+
```
126+
103127
## Contributing
104128

105129
Feel free to open tickets or send pull requests with improvements. Thanks in advance for your help!

go.mod

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ module github.com/TuanKiri/weather-mcp-server
22

33
go 1.24.1
44

5-
require github.com/mark3labs/mcp-go v0.18.0
5+
require (
6+
github.com/mark3labs/mcp-go v0.18.0
7+
github.com/stretchr/testify v1.9.0
8+
go.uber.org/mock v0.5.1
9+
)
610

711
require (
12+
github.com/davecgh/go-spew v1.1.1 // indirect
813
github.com/google/uuid v1.6.0 // indirect
14+
github.com/pmezard/go-difflib v1.0.0 // indirect
915
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
16+
gopkg.in/yaml.v3 v3.0.1 // indirect
1017
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,9 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
1010
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
1111
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
1212
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
13+
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
14+
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
15+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
16+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1317
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1418
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/mark3labs/mcp-go/mcp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/mock/gomock"
12+
13+
"github.com/TuanKiri/weather-mcp-server/internal/server/services/mock"
14+
)
15+
16+
func TestCurrentWeather(t *testing.T) {
17+
testCases := map[string]struct {
18+
arguments map[string]any
19+
errString string
20+
wait string
21+
setupWeatherService func(mocksWeather *mock.MockWeatherService)
22+
}{
23+
"empty_city": {
24+
wait: "city must be a string",
25+
},
26+
"city_not_found": {
27+
arguments: map[string]any{
28+
"city": "Tokyo",
29+
},
30+
errString: "weather API not available. Code: 400",
31+
setupWeatherService: func(mocksWeather *mock.MockWeatherService) {
32+
mocksWeather.EXPECT().
33+
Current(context.Background(), "Tokyo").
34+
Return("", errors.New("weather API not available. Code: 400"))
35+
},
36+
},
37+
"successful_request": {
38+
arguments: map[string]any{
39+
"city": "London",
40+
},
41+
wait: "<h1>London weather data</h1>",
42+
setupWeatherService: func(mocksWeather *mock.MockWeatherService) {
43+
mocksWeather.EXPECT().
44+
Current(context.Background(), "London").
45+
Return("<h1>London weather data</h1>", nil)
46+
},
47+
},
48+
}
49+
50+
ctrl := gomock.NewController(t)
51+
defer ctrl.Finish()
52+
53+
mocksWeather := mock.NewMockWeatherService(ctrl)
54+
55+
svc := mock.NewMockServices(ctrl)
56+
svc.EXPECT().Weather().Return(mocksWeather).AnyTimes()
57+
58+
handler := CurrentWeather(svc)
59+
60+
for name, tc := range testCases {
61+
t.Run(name, func(t *testing.T) {
62+
if tc.setupWeatherService != nil {
63+
tc.setupWeatherService(mocksWeather)
64+
}
65+
66+
var request mcp.CallToolRequest
67+
request.Params.Arguments = tc.arguments
68+
69+
result, err := handler(context.Background(), request)
70+
if err != nil {
71+
assert.EqualError(t, err, tc.errString)
72+
return
73+
}
74+
75+
require.Len(t, result.Content, 1)
76+
content, ok := result.Content[0].(mcp.TextContent)
77+
require.True(t, ok)
78+
79+
assert.Equal(t, tc.wait, content.Text)
80+
})
81+
}
82+
}

internal/server/services/core/core.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,16 @@ import (
44
"html/template"
55

66
"github.com/TuanKiri/weather-mcp-server/internal/server/services"
7-
"github.com/TuanKiri/weather-mcp-server/pkg/weatherapi"
87
)
98

109
type CoreServices struct {
1110
renderer *template.Template
12-
weatherAPI *weatherapi.WeatherAPI
11+
weatherAPI services.WeatherAPIProvider
1312

1413
weatherService *WeatherService
1514
}
1615

17-
func New(renderer *template.Template, weatherAPI *weatherapi.WeatherAPI) *CoreServices {
16+
func New(renderer *template.Template, weatherAPI services.WeatherAPIProvider) *CoreServices {
1817
return &CoreServices{
1918
renderer: renderer,
2019
weatherAPI: weatherAPI,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"errors"
6+
"html/template"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/mock/gomock"
12+
13+
"github.com/TuanKiri/weather-mcp-server/internal/server/services/mock"
14+
"github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models"
15+
)
16+
17+
func TestCurrentWeather(t *testing.T) {
18+
testCases := map[string]struct {
19+
city string
20+
errString string
21+
wait string
22+
setupWeatherAPI func(weatherAPI *mock.MockWeatherAPIProvider)
23+
}{
24+
"city_not_found": {
25+
city: "Tokyo",
26+
errString: "weather API not available. Code: 400",
27+
setupWeatherAPI: func(weatherAPI *mock.MockWeatherAPIProvider) {
28+
weatherAPI.EXPECT().
29+
Current(context.Background(), "Tokyo").
30+
Return(nil, errors.New("weather API not available. Code: 400"))
31+
},
32+
},
33+
"successful_result": {
34+
city: "London",
35+
wait: "London, United Kingdom Sunny 18 45 4 " +
36+
"https://cdn.weatherapi.com/weather/64x64/day/113.png",
37+
setupWeatherAPI: func(weatherAPI *mock.MockWeatherAPIProvider) {
38+
weatherAPI.EXPECT().
39+
Current(context.Background(), "London").
40+
Return(&models.CurrentResponse{
41+
Location: models.Location{
42+
Name: "London",
43+
Country: "United Kingdom",
44+
},
45+
Current: models.Current{
46+
TempC: 18.4,
47+
WindKph: 4.2,
48+
Humidity: 45,
49+
Condition: models.Condition{
50+
Text: "Sunny",
51+
Icon: "//cdn.weatherapi.com/weather/64x64/day/113.png",
52+
},
53+
},
54+
}, nil)
55+
},
56+
},
57+
}
58+
59+
renderer, err := template.New("weather.html").Parse(
60+
"{{ .Location }} {{ .Condition }} {{ .Temperature }} " +
61+
"{{ .Humidity }} {{ .WindSpeed }} {{ .Icon }}")
62+
require.NoError(t, err)
63+
64+
ctrl := gomock.NewController(t)
65+
defer ctrl.Finish()
66+
67+
weatherAPI := mock.NewMockWeatherAPIProvider(ctrl)
68+
69+
svc := New(renderer, weatherAPI)
70+
71+
for name, tc := range testCases {
72+
t.Run(name, func(t *testing.T) {
73+
if tc.setupWeatherAPI != nil {
74+
tc.setupWeatherAPI(weatherAPI)
75+
}
76+
77+
data, err := svc.Weather().Current(context.Background(), tc.city)
78+
if err != nil {
79+
assert.EqualError(t, err, tc.errString)
80+
}
81+
82+
assert.Equal(t, tc.wait, data)
83+
})
84+
}
85+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package services
2+
3+
import (
4+
"context"
5+
6+
"github.com/TuanKiri/weather-mcp-server/pkg/weatherapi/models"
7+
)
8+
9+
//go:generate mockgen --source external.go --destination mock/external_mock.go --package mock
10+
11+
type WeatherAPIProvider interface {
12+
Current(ctx context.Context, city string) (*models.CurrentResponse, error)
13+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.go
2+
!.gitignore

0 commit comments

Comments
 (0)