diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md
index 4154de352f..54e37c4f23 100644
--- a/src/vertex/app/changelog.md
+++ b/src/vertex/app/changelog.md
@@ -2,6 +2,58 @@
> **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend.
+## Version 2.0.8
+**Released:** June 28, 2026
+
+### Comprehensive Unit Testing & CI Integration
+
+Introduced a robust unit testing infrastructure using Vitest and React Testing Library, alongside comprehensive CI/CD pipeline integration. This release focuses on improving code quality, ensuring robust component rendering, and validating API proxy behavior through extensive boundary testing.
+
+
+Test Infrastructure & CI Integration (3)
+
+- **Vitest & React Testing Library**: Replaced legacy test runners with a fast, modern Vitest setup configured to work seamlessly with Next.js App Router and absolute imports.
+- **CI/CD Pipeline**: Added dedicated GitHub Actions workflows for automated type checking (`tsc --noEmit`) and unit testing (`vitest run --coverage`) on pull requests.
+- **Testing Guidelines**: Introduced `TESTING.md` internal documentation to standardize component rendering, user-event interactions, and mocking strategies across the team.
+
+
+
+
+UI & Shared Component Testing (3)
+
+- **Core UI Components**: Added comprehensive tests for 30+ shadcn/radix UI components (Dialogs, Accords, Tooltips, Buttons, Inputs, Forms, etc.) verifying accessibility and interaction states without snapshot testing.
+- **Shared Abstractions**: Extracted and tested shared wrappers including `ReusableTable`, `ReusableSelectInput`, `ReusableButton`, and `ReusableFileUpload`, ensuring robust prop forwarding and DOM nesting compliance.
+- **Warning Resolutions**: Proactively fixed React DOM nesting warnings (e.g., `` cannot appear as a descendant of ``) and accessibility label warnings across components during test implementation.
+
+
+
+
+Service & API Boundary Testing (2)
+
+- **Mock Factories**: Created `apiResponseFactory.ts` and NextAuth session mocks to simulate full HTTP environments locally without hitting live staging endpoints or needing `axios-mock-adapter`.
+- **Proxy Validation**: Validated both the Next.js App Router API handlers (`proxyClient`) and client-side Axios instances (`secureApiProxyClient`), verifying `JWT` vs `API_TOKEN` injection, error normalization, and custom event dispatching (`vertex-network-degraded`, `auth-token-expired`).
+
+
+
+
+Files Modified & Added (40+)
+
+- `package.json` [MODIFIED]
+- `vitest.config.ts` [NEW]
+- `.github/workflows/test.yml` [NEW]
+- `src/vertex/app/_docs/internal/TESTING.md` [NEW]
+- `src/vertex/components/ui/*.test.tsx` (30+ files) [NEW]
+- `src/vertex/components/shared/**/*.test.tsx` [NEW]
+- `src/vertex/core/services/*.test.ts` [NEW]
+- `src/vertex/core/utils/*.test.ts` [NEW]
+- `src/vertex/test/factories/apiResponseFactory.ts` [NEW]
+- `src/vertex/test/mocks/nextAuth.ts` [NEW]
+- `src/vertex/vitest.setup.ts` [NEW]
+
+
+
+---
+
## Version 2.0.7
**Released:** June 17, 2026
diff --git a/src/vertex/components/shared/button/ReusableButton.test.tsx b/src/vertex/components/shared/button/ReusableButton.test.tsx
new file mode 100644
index 0000000000..a29a4b26f3
--- /dev/null
+++ b/src/vertex/components/shared/button/ReusableButton.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import ReusableButton from "./ReusableButton";
+
+describe("ReusableButton", () => {
+ it("renders as a button by default", () => {
+ render(Click me);
+ expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
+ });
+
+ it("renders as an anchor when path is provided", () => {
+ render(Go Home);
+ expect(screen.getByRole("link", { name: "Go Home" })).toBeInTheDocument();
+ });
+
+ it("handles loading state", () => {
+ render(Submit);
+ const button = screen.getByRole("button", { name: "Submit" });
+ expect(button).toHaveAttribute("aria-busy", "true");
+ expect(button).toBeDisabled();
+ });
+
+ it("renders tooltip span when disabled with permission", () => {
+ render(Action);
+ const actionText = screen.getByText("Action");
+ expect(actionText).toBeInTheDocument();
+ });
+});
diff --git a/src/vertex/components/shared/card/CardWrapper.test.tsx b/src/vertex/components/shared/card/CardWrapper.test.tsx
new file mode 100644
index 0000000000..e9507e3a31
--- /dev/null
+++ b/src/vertex/components/shared/card/CardWrapper.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import Card from "./CardWrapper";
+
+describe("CardWrapper", () => {
+ it("renders with basic content", () => {
+ render(Card Content);
+ expect(screen.getByText("Card Content")).toBeInTheDocument();
+ });
+
+ it("renders with headers and footers", () => {
+ render(
+ Header} footer={Footer
}>
+ Content
+
+ );
+ expect(screen.getByTestId("header")).toBeInTheDocument();
+ expect(screen.getByTestId("footer")).toBeInTheDocument();
+ });
+
+ it("handles clicks", async () => {
+ const handleClick = vi.fn();
+ render(Content);
+ await userEvent.click(screen.getByTestId("clickable-card"));
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx b/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx
new file mode 100644
index 0000000000..9b54d69065
--- /dev/null
+++ b/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import ReusableFileUpload from "./ReusableFileUpload";
+
+describe("ReusableFileUpload", () => {
+ it("renders with placeholder and label", () => {
+ render();
+ expect(screen.getByLabelText("Upload File")).toBeInTheDocument();
+ expect(screen.getByText("Upload file")).toBeInTheDocument(); // default placeholder
+ });
+
+ it("handles file selection", () => {
+ const handleChange = vi.fn();
+ render();
+
+ const file = new File(["hello"], "hello.png", { type: "image/png" });
+ const input = screen.getByLabelText("Upload");
+
+ fireEvent.change(input, { target: { files: [file] } });
+ expect(handleChange).toHaveBeenCalledWith(file);
+ });
+
+ it("shows error state", () => {
+ render();
+ expect(screen.getByText("File too large")).toBeInTheDocument();
+ });
+});
diff --git a/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx b/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx
new file mode 100644
index 0000000000..9a5eca5fb2
--- /dev/null
+++ b/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx
@@ -0,0 +1,60 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import ReusableInputField from "./ReusableInputField";
+
+// Mock toast to avoid missing canvas/window functions in jsdom sometimes or to just isolate
+vi.mock("../toast/ReusableToast", () => ({
+ default: vi.fn(),
+}));
+
+// Mock clipboard
+Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn(),
+ },
+});
+
+describe("ReusableInputField", () => {
+ it("renders basic input", () => {
+ render();
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Enter email")).toBeInTheDocument();
+ });
+
+ it("shows error message", () => {
+ render();
+ expect(screen.getByText("Invalid email")).toBeInTheDocument();
+ });
+
+ it("toggles password visibility", async () => {
+ render();
+ const input = screen.getByLabelText("Password");
+ expect(input).toHaveAttribute("type", "password");
+
+ const toggleBtn = screen.getByRole("button", { name: /show password/i });
+ await userEvent.click(toggleBtn);
+ expect(input).toHaveAttribute("type", "text");
+
+ await userEvent.click(screen.getByRole("button", { name: /hide password/i }));
+ expect(input).toHaveAttribute("type", "password");
+ });
+
+ it("calls custom action", async () => {
+ const handleAction = vi.fn();
+ const MockIcon = () =>
;
+
+ render(
+
+ );
+
+ const actionBtn = screen.getByRole("button", { name: "Do search" });
+ await userEvent.click(actionBtn);
+ expect(handleAction).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/vertex/components/shared/select/ReusableSelectInput.test.tsx b/src/vertex/components/shared/select/ReusableSelectInput.test.tsx
new file mode 100644
index 0000000000..28a877149e
--- /dev/null
+++ b/src/vertex/components/shared/select/ReusableSelectInput.test.tsx
@@ -0,0 +1,60 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import ReusableSelectInput from "./ReusableSelectInput";
+
+window.HTMLElement.prototype.scrollIntoView = vi.fn();
+
+describe("ReusableSelectInput", () => {
+ it("renders placeholder correctly", () => {
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+ expect(screen.getByText("Pick fruit")).toBeInTheDocument();
+ });
+
+ it("shows options and handles selection", async () => {
+ const handleChange = vi.fn();
+ render(
+
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ await userEvent.click(button);
+
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
+ const bananaOption = screen.getByText("Banana");
+
+ await userEvent.click(bananaOption);
+
+ expect(handleChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: expect.objectContaining({ value: "banana" })
+ })
+ );
+ });
+
+ it("handles keyboard navigation", async () => {
+ render(
+
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ button.focus();
+
+ // Open with arrow down
+ fireEvent.keyDown(button, { key: "ArrowDown" });
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
+ });
+});
diff --git a/src/vertex/components/shared/table/ReusableTable.test.tsx b/src/vertex/components/shared/table/ReusableTable.test.tsx
new file mode 100644
index 0000000000..d0689573e5
--- /dev/null
+++ b/src/vertex/components/shared/table/ReusableTable.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import ReusableTable from "./ReusableTable";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ replace: vi.fn() }),
+ usePathname: () => "/test-path",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock("@/context/banner-context", () => ({
+ useBanner: () => ({ showBanner: vi.fn(), hideBanner: vi.fn() }),
+}));
+
+describe("ReusableTable", () => {
+ it("renders with empty data", () => {
+ render();
+ expect(screen.getByText("Test Table")).toBeInTheDocument();
+ expect(screen.getByText("No data available")).toBeInTheDocument();
+ });
+
+ it("renders data correctly", () => {
+ const columns = [
+ { key: "name", render: (val: unknown) => String(val) },
+ ];
+ const data = [
+ { id: 1, name: "Alice" },
+ { id: 2, name: "Bob" }
+ ];
+
+ render();
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("Bob")).toBeInTheDocument();
+ });
+});
diff --git a/src/vertex/components/shared/table/ReusableTable.tsx b/src/vertex/components/shared/table/ReusableTable.tsx
index caa9e8835c..2b6c25c752 100644
--- a/src/vertex/components/shared/table/ReusableTable.tsx
+++ b/src/vertex/components/shared/table/ReusableTable.tsx
@@ -1472,7 +1472,7 @@ const ReusableTable = ({
)}
{/* Add CSS to hide scrollbar on thead */}
-