From 97fb26b3e22b9635425cbdf252729e1fa2731fef Mon Sep 17 00:00:00 2001 From: Badre Tejado-Imam Date: Mon, 14 Sep 2026 13:50:45 -0400 Subject: [PATCH 1/2] feat: add unit tests and Codecov integration for code coverage tracking Add unit tests for pkg/config and pkg/webterminal packages, a GitHub Actions workflow that runs tests with coverage on push to main and PRs, and uploads results to Codecov via OIDC with the unit-tests flag. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/unit-tests.yml | 50 ++++++++++ .gitignore | 1 + Makefile | 9 ++ codecov.yml | 34 +++++++ pkg/config/config_test.go | 102 ++++++++++++++++++++ pkg/webterminal/exec_test.go | 158 +++++++++++++++++++++++++++++++ pkg/webterminal/tooling_test.go | 158 +++++++++++++++++++++++++++++++ 7 files changed, 512 insertions(+) create mode 100644 .github/workflows/unit-tests.yml create mode 100644 codecov.yml create mode 100644 pkg/config/config_test.go create mode 100644 pkg/webterminal/exec_test.go create mode 100644 pkg/webterminal/tooling_test.go diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..1e85c5b --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,50 @@ +# +# Copyright (c) 2020-2024 Red Hat, Inc. +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# +name: Unit Tests & Coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit-tests: + name: Run unit tests + runs-on: ubuntu-24.04 + permissions: + id-token: write + + steps: + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.5' + + - name: Check out code + uses: actions/checkout@v4 + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run unit tests with coverage + run: make test-coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v6 + with: + use_oidc: true + flags: unit-tests + files: ./coverage.out + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 0a3d36b..b2c318c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea _output generated +coverage.out diff --git a/Makefile b/Makefile index 86e6d65..c37f114 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,15 @@ include build/makefiles/version.mk all: help +### test: Run unit tests +test: + go test -v ./pkg/... + +### test-coverage: Run unit tests with coverage report +test-coverage: + go test -v -coverprofile=coverage.out -covermode=atomic ./pkg/... + go tool cover -func=coverage.out + _print_vars: echo "Current env vars:" echo " WTO_IMG=$(WTO_IMG)" diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..a0ecadb --- /dev/null +++ b/codecov.yml @@ -0,0 +1,34 @@ +codecov: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "50...100" + + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: auto + threshold: 1% + +ignore: + - "vendor/**" + - "build/**" + - "hack/**" + - "docs/**" + - "manifests/**" + - "metadata/**" + - "dev-scripts/**" + +flags: + unit-tests: + carryforward: true + +comment: + layout: "reach,diff,flags,files" + behavior: default diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..71df54e --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,102 @@ +// +// Copyright (c) 2021-2024 Red Hat, Inc. +// This program and the accompanying materials are made +// available under the terms of the Eclipse Public License 2.0 +// which is available at https://www.eclipse.org/legal/epl-2.0/ +// +// SPDX-License-Identifier: EPL-2.0 +// + +package config + +import ( + "os" + "testing" +) + +func TestGetDefaultToolingImage(t *testing.T) { + tests := []struct { + name string + envVal string + want string + wantErr bool + }{ + { + name: "returns image when env var is set", + envVal: "quay.io/wto/web-terminal-tooling:latest", + want: "quay.io/wto/web-terminal-tooling:latest", + wantErr: false, + }, + { + name: "returns error when env var is unset", + envVal: "", + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVal != "" { + t.Setenv(toolingImageEnvVar, tt.envVal) + } else { + os.Unsetenv(toolingImageEnvVar) + } + got, err := GetDefaultToolingImage() + if (err != nil) != tt.wantErr { + t.Errorf("GetDefaultToolingImage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetDefaultToolingImage() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetDefaultExecImage(t *testing.T) { + tests := []struct { + name string + envVal string + want string + wantErr bool + }{ + { + name: "returns image when env var is set", + envVal: "quay.io/wto/web-terminal-exec:latest", + want: "quay.io/wto/web-terminal-exec:latest", + wantErr: false, + }, + { + name: "returns error when env var is unset", + envVal: "", + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVal != "" { + t.Setenv(execImageEnvVar, tt.envVal) + } else { + os.Unsetenv(execImageEnvVar) + } + got, err := GetDefaultExecImage() + if (err != nil) != tt.wantErr { + t.Errorf("GetDefaultExecImage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetDefaultExecImage() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetNamespace(t *testing.T) { + _, err := GetNamespace() + if err == nil { + t.Log("GetNamespace() succeeded — running in a Kubernetes pod") + return + } + t.Log("GetNamespace() returned expected error in non-cluster environment:", err) +} diff --git a/pkg/webterminal/exec_test.go b/pkg/webterminal/exec_test.go new file mode 100644 index 0000000..f0a3b1b --- /dev/null +++ b/pkg/webterminal/exec_test.go @@ -0,0 +1,158 @@ +// +// Copyright (c) 2021-2024 Red Hat, Inc. +// This program and the accompanying materials are made +// available under the terms of the Eclipse Public License 2.0 +// which is available at https://www.eclipse.org/legal/epl-2.0/ +// +// SPDX-License-Identifier: EPL-2.0 +// + +package webterminal + +import ( + "os" + "testing" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/redhat-developer/web-terminal-operator/pkg/config" +) + +func makeExecTemplate(image string) *dw.DevWorkspaceTemplate { + return &dw.DevWorkspaceTemplate{ + ObjectMeta: v1.ObjectMeta{ + Name: config.ExecTemplateName, + Namespace: "test-ns", + }, + Spec: dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Components: []dw.Component{ + { + Name: config.ExecTemplateName, + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{ + Image: image, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestHandleUnmanagedExecState(t *testing.T) { + const defaultImage = "quay.io/wto/web-terminal-exec:latest" + + tests := []struct { + name string + specImage string + clusterImage string + expectedImage string + }{ + { + name: "same image returns cluster unchanged", + specImage: defaultImage, + clusterImage: defaultImage, + expectedImage: defaultImage, + }, + { + name: "same repo different tag updates image", + specImage: defaultImage, + clusterImage: "quay.io/wto/web-terminal-exec:old", + expectedImage: defaultImage, + }, + { + name: "same repo different digest updates image", + specImage: defaultImage, + clusterImage: "quay.io/wto/web-terminal-exec@sha256:abc123", + expectedImage: defaultImage, + }, + { + name: "different repo does not update", + specImage: defaultImage, + clusterImage: "quay.io/other/custom-exec:latest", + expectedImage: "quay.io/other/custom-exec:latest", + }, + { + name: "empty cluster image returns cluster unchanged", + specImage: defaultImage, + clusterImage: "", + expectedImage: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("RELATED_IMAGE_web_terminal_exec", tt.specImage) + + spec := makeExecTemplate(tt.specImage) + cluster := makeExecTemplate(tt.clusterImage) + + result := handleUnmanagedExecState(spec, cluster) + + var resultImage string + for _, component := range result.Spec.Components { + if component.Name == config.ExecTemplateName && component.Container != nil { + resultImage = component.Container.Image + } + } + if resultImage != tt.expectedImage { + t.Errorf("expected image %q, got %q", tt.expectedImage, resultImage) + } + }) + } +} + +func TestHandleUnmanagedExecStateEnvUnset(t *testing.T) { + os.Unsetenv("RELATED_IMAGE_web_terminal_exec") + + spec := makeExecTemplate("any-image") + cluster := makeExecTemplate("quay.io/wto/web-terminal-exec:old") + + result := handleUnmanagedExecState(spec, cluster) + + var resultImage string + for _, component := range result.Spec.Components { + if component.Name == config.ExecTemplateName && component.Container != nil { + resultImage = component.Container.Image + } + } + if resultImage != "quay.io/wto/web-terminal-exec:old" { + t.Errorf("expected cluster image unchanged when env var unset, got %q", resultImage) + } +} + +func TestGetSpecExecTemplate(t *testing.T) { + const testImage = "quay.io/wto/web-terminal-exec:test" + t.Setenv("RELATED_IMAGE_web_terminal_exec", testImage) + + dwt, err := getSpecExecTemplate("test-namespace") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dwt.Name != config.ExecTemplateName { + t.Errorf("expected name %q, got %q", config.ExecTemplateName, dwt.Name) + } + if dwt.Namespace != "test-namespace" { + t.Errorf("expected namespace %q, got %q", "test-namespace", dwt.Namespace) + } + if len(dwt.Spec.Components) != 1 { + t.Fatalf("expected 1 component, got %d", len(dwt.Spec.Components)) + } + if dwt.Spec.Components[0].Container.Image != testImage { + t.Errorf("expected image %q, got %q", testImage, dwt.Spec.Components[0].Container.Image) + } +} + +func TestGetSpecExecTemplateEnvUnset(t *testing.T) { + os.Unsetenv("RELATED_IMAGE_web_terminal_exec") + + _, err := getSpecExecTemplate("test-namespace") + if err == nil { + t.Error("expected error when env var is unset") + } +} diff --git a/pkg/webterminal/tooling_test.go b/pkg/webterminal/tooling_test.go new file mode 100644 index 0000000..17cbe99 --- /dev/null +++ b/pkg/webterminal/tooling_test.go @@ -0,0 +1,158 @@ +// +// Copyright (c) 2021-2024 Red Hat, Inc. +// This program and the accompanying materials are made +// available under the terms of the Eclipse Public License 2.0 +// which is available at https://www.eclipse.org/legal/epl-2.0/ +// +// SPDX-License-Identifier: EPL-2.0 +// + +package webterminal + +import ( + "os" + "testing" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/redhat-developer/web-terminal-operator/pkg/config" +) + +func makeToolingTemplate(image string) *dw.DevWorkspaceTemplate { + return &dw.DevWorkspaceTemplate{ + ObjectMeta: v1.ObjectMeta{ + Name: config.ToolingTemplateName, + Namespace: "test-ns", + }, + Spec: dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Components: []dw.Component{ + { + Name: config.ToolingTemplateName, + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{ + Image: image, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestHandleUnmanagedToolingState(t *testing.T) { + const defaultImage = "quay.io/wto/web-terminal-tooling:latest" + + tests := []struct { + name string + specImage string + clusterImage string + expectedImage string + }{ + { + name: "same image returns cluster unchanged", + specImage: defaultImage, + clusterImage: defaultImage, + expectedImage: defaultImage, + }, + { + name: "same repo different tag updates image", + specImage: defaultImage, + clusterImage: "quay.io/wto/web-terminal-tooling:old", + expectedImage: defaultImage, + }, + { + name: "same repo different digest updates image", + specImage: defaultImage, + clusterImage: "quay.io/wto/web-terminal-tooling@sha256:abc123", + expectedImage: defaultImage, + }, + { + name: "different repo does not update", + specImage: defaultImage, + clusterImage: "quay.io/other/custom-tooling:latest", + expectedImage: "quay.io/other/custom-tooling:latest", + }, + { + name: "empty cluster image returns cluster unchanged", + specImage: defaultImage, + clusterImage: "", + expectedImage: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("RELATED_IMAGE_web_terminal_tooling", tt.specImage) + + spec := makeToolingTemplate(tt.specImage) + cluster := makeToolingTemplate(tt.clusterImage) + + result := handleUnmanagedToolingState(spec, cluster) + + var resultImage string + for _, component := range result.Spec.Components { + if component.Name == config.ToolingTemplateName && component.Container != nil { + resultImage = component.Container.Image + } + } + if resultImage != tt.expectedImage { + t.Errorf("expected image %q, got %q", tt.expectedImage, resultImage) + } + }) + } +} + +func TestHandleUnmanagedToolingStateEnvUnset(t *testing.T) { + os.Unsetenv("RELATED_IMAGE_web_terminal_tooling") + + spec := makeToolingTemplate("any-image") + cluster := makeToolingTemplate("quay.io/wto/web-terminal-tooling:old") + + result := handleUnmanagedToolingState(spec, cluster) + + var resultImage string + for _, component := range result.Spec.Components { + if component.Name == config.ToolingTemplateName && component.Container != nil { + resultImage = component.Container.Image + } + } + if resultImage != "quay.io/wto/web-terminal-tooling:old" { + t.Errorf("expected cluster image unchanged when env var unset, got %q", resultImage) + } +} + +func TestGetSpecToolingTemplate(t *testing.T) { + const testImage = "quay.io/wto/web-terminal-tooling:test" + t.Setenv("RELATED_IMAGE_web_terminal_tooling", testImage) + + dwt, err := getSpecToolingTemplate("test-namespace") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dwt.Name != config.ToolingTemplateName { + t.Errorf("expected name %q, got %q", config.ToolingTemplateName, dwt.Name) + } + if dwt.Namespace != "test-namespace" { + t.Errorf("expected namespace %q, got %q", "test-namespace", dwt.Namespace) + } + if len(dwt.Spec.Components) != 1 { + t.Fatalf("expected 1 component, got %d", len(dwt.Spec.Components)) + } + if dwt.Spec.Components[0].Container.Image != testImage { + t.Errorf("expected image %q, got %q", testImage, dwt.Spec.Components[0].Container.Image) + } +} + +func TestGetSpecToolingTemplateEnvUnset(t *testing.T) { + os.Unsetenv("RELATED_IMAGE_web_terminal_tooling") + + _, err := getSpecToolingTemplate("test-namespace") + if err == nil { + t.Error("expected error when env var is unset") + } +} From f5f58b7ff3296d1f4a3d5292139243ca525d2815 Mon Sep 17 00:00:00 2001 From: Badre Tejado-Imam Date: Wed, 16 Sep 2026 14:42:49 -0400 Subject: [PATCH 2/2] fix: address PR review feedback - Make TestGetNamespace deterministic by asserting expected error - Replace os.Unsetenv() with t.Setenv("", "") for proper test cleanup - Swap checkout/setup-go order and use go-version-file to avoid drift - Drop manual cache step (setup-go built-in cache handles it) - Add .PHONY declaration for test targets Co-Authored-By: Claude Opus 4.6 --- .github/workflows/unit-tests.yml | 15 ++++----------- Makefile | 2 ++ pkg/config/config_test.go | 19 ++++--------------- pkg/webterminal/exec_test.go | 5 ++--- pkg/webterminal/tooling_test.go | 5 ++--- 5 files changed, 14 insertions(+), 32 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 1e85c5b..de80258 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -22,21 +22,14 @@ jobs: id-token: write steps: - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.26.5' - - name: Check out code uses: actions/checkout@v4 - - name: Cache Go modules - uses: actions/cache@v4 + - name: Set up Go + uses: actions/setup-go@v5 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + go-version-file: go.mod + cache: true - name: Run unit tests with coverage run: make test-coverage diff --git a/Makefile b/Makefile index c37f114..6cc47f0 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ include build/makefiles/version.mk all: help +.PHONY: test test-coverage + ### test: Run unit tests test: go test -v ./pkg/... diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 71df54e..baf9270 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -10,7 +10,6 @@ package config import ( - "os" "testing" ) @@ -36,11 +35,7 @@ func TestGetDefaultToolingImage(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.envVal != "" { - t.Setenv(toolingImageEnvVar, tt.envVal) - } else { - os.Unsetenv(toolingImageEnvVar) - } + t.Setenv(toolingImageEnvVar, tt.envVal) got, err := GetDefaultToolingImage() if (err != nil) != tt.wantErr { t.Errorf("GetDefaultToolingImage() error = %v, wantErr %v", err, tt.wantErr) @@ -75,11 +70,7 @@ func TestGetDefaultExecImage(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.envVal != "" { - t.Setenv(execImageEnvVar, tt.envVal) - } else { - os.Unsetenv(execImageEnvVar) - } + t.Setenv(execImageEnvVar, tt.envVal) got, err := GetDefaultExecImage() if (err != nil) != tt.wantErr { t.Errorf("GetDefaultExecImage() error = %v, wantErr %v", err, tt.wantErr) @@ -92,11 +83,9 @@ func TestGetDefaultExecImage(t *testing.T) { } } -func TestGetNamespace(t *testing.T) { +func TestGetNamespaceReturnsErrorOutsideCluster(t *testing.T) { _, err := GetNamespace() if err == nil { - t.Log("GetNamespace() succeeded — running in a Kubernetes pod") - return + t.Fatal("expected error when service account namespace file does not exist") } - t.Log("GetNamespace() returned expected error in non-cluster environment:", err) } diff --git a/pkg/webterminal/exec_test.go b/pkg/webterminal/exec_test.go index f0a3b1b..30a2dfd 100644 --- a/pkg/webterminal/exec_test.go +++ b/pkg/webterminal/exec_test.go @@ -10,7 +10,6 @@ package webterminal import ( - "os" "testing" dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" @@ -108,7 +107,7 @@ func TestHandleUnmanagedExecState(t *testing.T) { } func TestHandleUnmanagedExecStateEnvUnset(t *testing.T) { - os.Unsetenv("RELATED_IMAGE_web_terminal_exec") + t.Setenv("RELATED_IMAGE_web_terminal_exec", "") spec := makeExecTemplate("any-image") cluster := makeExecTemplate("quay.io/wto/web-terminal-exec:old") @@ -149,7 +148,7 @@ func TestGetSpecExecTemplate(t *testing.T) { } func TestGetSpecExecTemplateEnvUnset(t *testing.T) { - os.Unsetenv("RELATED_IMAGE_web_terminal_exec") + t.Setenv("RELATED_IMAGE_web_terminal_exec", "") _, err := getSpecExecTemplate("test-namespace") if err == nil { diff --git a/pkg/webterminal/tooling_test.go b/pkg/webterminal/tooling_test.go index 17cbe99..07b5b7e 100644 --- a/pkg/webterminal/tooling_test.go +++ b/pkg/webterminal/tooling_test.go @@ -10,7 +10,6 @@ package webterminal import ( - "os" "testing" dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" @@ -108,7 +107,7 @@ func TestHandleUnmanagedToolingState(t *testing.T) { } func TestHandleUnmanagedToolingStateEnvUnset(t *testing.T) { - os.Unsetenv("RELATED_IMAGE_web_terminal_tooling") + t.Setenv("RELATED_IMAGE_web_terminal_tooling", "") spec := makeToolingTemplate("any-image") cluster := makeToolingTemplate("quay.io/wto/web-terminal-tooling:old") @@ -149,7 +148,7 @@ func TestGetSpecToolingTemplate(t *testing.T) { } func TestGetSpecToolingTemplateEnvUnset(t *testing.T) { - os.Unsetenv("RELATED_IMAGE_web_terminal_tooling") + t.Setenv("RELATED_IMAGE_web_terminal_tooling", "") _, err := getSpecToolingTemplate("test-namespace") if err == nil {