diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..c1a500d
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,19 @@
+name: Tests
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Go stable
+ uses: actions/setup-go@v5
+ with:
+ go-version: 'stable'
+ - name: "Running unit tests"
+ run: go test ./... -v
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 1f5d6dd..6b88329 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,9 @@ WORKDIR /app
# Copy source files
COPY . .
+# Run unit tests
+RUN go test ./... -v
+
# Build the application
RUN go build -ldflags="-s -w" -o zephyr
diff --git a/README.md b/README.md
index eadf520..8ab9d24 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
real-time weather forecast service
[](https://github.com/ceticamarco/zephyr/actions/workflows/docker.yml)
+[](https://github.com/ceticamarco/zephyr/actions/workflows/tests.yml)
@@ -163,7 +164,7 @@ will yield
}
```
-> [!INFO]
+> [!NOTE]
> To convert OpenWeatherMap's moon phase value to the illumination percentage,
> I've used the following formula:
>
@@ -333,5 +334,12 @@ This will build the container image and start the service in detached mode. By d
the service will be available at `http://127.0.0.1:3000`, but you can easily change this property
but editing the `compose.yml` as stated above.
+## Unit tests
+You can run the unit tests by issuing the following command:
+
+```sh
+ go test ./... -v
+ ```
+
## License
This software is released under the GPLv3 license. You can find a copy of the license with this repository or by visiting the [following page](https://choosealicense.com/licenses/gpl-3.0/).
\ No newline at end of file
diff --git a/model/windModel_test.go b/model/windModel_test.go
new file mode 100644
index 0000000..d9d8f2c
--- /dev/null
+++ b/model/windModel_test.go
@@ -0,0 +1,28 @@
+package model
+
+import (
+ "testing"
+)
+
+type TestEntry struct {
+ Name string
+ Input float64
+ Expected string
+}
+
+func TestGetCardinalDir(t *testing.T) {
+ tests := []TestEntry{
+ {"Bounded value", 65.4, "ENE"},
+ {"Out of bound value", 450.3, "E"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ got, _ := GetCardinalDir(test.Input)
+
+ if got != test.Expected {
+ t.Errorf("Got %s, wanted %s", got, test.Expected)
+ }
+ })
+ }
+}
diff --git a/statistics/primitives_test.go b/statistics/primitives_test.go
new file mode 100644
index 0000000..557e8ed
--- /dev/null
+++ b/statistics/primitives_test.go
@@ -0,0 +1,123 @@
+package statistics
+
+import (
+ "math"
+ "testing"
+)
+
+type TestEntry struct {
+ Name string
+ Input []float64
+ Expected float64
+}
+
+func cmpVal(x, y float64) bool {
+ const epsilon = 1e-9
+
+ return math.Abs(x-y) < epsilon
+}
+
+func TestMean(t *testing.T) {
+ tests := []TestEntry{
+ {"Empty list", []float64{}, 0},
+ {"Single element", []float64{5.0}, 5.0},
+ {"Multiple elements", []float64{2.3, 6.4, -2.2, 8.4}, 3.725},
+ }
+
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ got := Mean(test.Input)
+ if !cmpVal(got, test.Expected) {
+ t.Errorf("Got %v, wanted %v", got, test.Expected)
+ }
+ })
+ }
+}
+
+func TestStdDev(t *testing.T) {
+ tests := []TestEntry{
+ {"Empty list", []float64{}, 0},
+ {"Single element", []float64{5.0}, 0},
+ {"Multiple elements", []float64{5.0, -4.2, 3.4, 7.2}, 4.288064831599448},
+ }
+
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ got := StdDev(test.Input)
+ if !cmpVal(got, test.Expected) {
+ t.Errorf("Got %v, wanted %v", got, test.Expected)
+ }
+ })
+ }
+}
+
+func TestMedian(t *testing.T) {
+ tests := []TestEntry{
+ {"Empty list", []float64{}, 0},
+ {"Single element", []float64{5.0}, 5.0},
+ {"Multiple elements (even)", []float64{5.0, -4.2, 3.4, 7.2}, 4.2},
+ {"Multiple elements (odd)", []float64{5.0, -4.2, 1.4, 3.4, 7.2}, 3.4},
+ }
+
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ got := Median(test.Input)
+ if !cmpVal(got, test.Expected) {
+ t.Errorf("Got %v, wanted %v", got, test.Expected)
+ }
+ })
+ }
+}
+
+func TestMode(t *testing.T) {
+ tests := []TestEntry{
+ {"Empty list", []float64{}, 0},
+ {"Single element", []float64{5.0}, 5.0},
+ {"Unique modes", []float64{1.0, 2.0, 2.0, 3.0}, 2.0},
+ {"Multi-modal", []float64{1.0, 1.0, 2.0, 3.0, 3.0}, 3.0},
+ }
+
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ got := Mode(test.Input)
+ if !cmpVal(got, test.Expected) {
+ t.Errorf("Got %v, wanted %v", got, test.Expected)
+ }
+ })
+ }
+}
+
+func TestRobustZScore(t *testing.T) {
+ // Gaussian distributed dataset representing "normal"
+ // temperatures; that is, without anomalies
+ normalTemps := []float64{
+ 18.0, 19.0, 19.0, 20.0, 20.0,
+ 20.0, 21.0, 21.0, 21.0, 21.0,
+ 22.0, 22.0, 22.0, 22.0, 22.0,
+ 23.0, 23.0, 23.0, 24.0, 24.0,
+ }
+
+ tests := []TestEntry{
+ {"Empty list", []float64{}, 0},
+ {"Single element", []float64{20.0}, 0},
+ {"Temperatures without anomalies", normalTemps, 0},
+ {"High anomaly", append(normalTemps, 30.0), 30.0},
+ {"Low anomaly", append(normalTemps, 5.0), 5.0},
+ }
+
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ got := RobustZScore(test.Input)
+
+ if len(got) != 0 {
+ if !cmpVal(got[0].Value, test.Expected) {
+ t.Errorf("Got %v, wanted %v", got, test.Expected)
+ }
+ } else {
+ if test.Expected != 0 {
+ t.Errorf("Got [], wanted %v", test.Expected)
+ }
+ }
+ })
+ }
+}