Angular in Practice #6: Testing and Deployment — Wrapping the Track

11 min read

Last time, we polished the dashboard’s looks with Angular Material. This time is the final post of Angular in Practice. We tighten the dashboard we’ve been hand-building with tests, wrap it with Docker, and move it to a place where it actually runs on a real domain — running the full cycle to the end.

And at the end, we step back and look across all 27 posts of the Angular track.

What we’ll finish #

We’re not adding new features. The work in this post is getting the code we’ve already built in front of real users — filling test coverage, Playwright E2E, environment splits, Docker builds, Cloudflare Pages deployment, and a GitHub Actions CI/CD pipeline.

Test coverage — starting with the Service #

We follow the same pattern from Intermediate #7. The AuthService we built in post 2 was a simple class that put a token into local storage and pulled it back out.

src/app/core/auth/auth.service.spec.ts
describe('AuthService', () => {
  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({ providers: [AuthService] });
  });

  it('login stores the token and makes isAuthenticated true', () => {
    const auth = TestBed.inject(AuthService);
    auth.login('curtis', 'password');
    expect(auth.isAuthenticated()).toBeTrue();
    expect(localStorage.getItem('auth_token')).toBeTruthy();
  });

  it('logout clears the token and resets state', () => {
    const auth = TestBed.inject(AuthService);
    auth.login('curtis', 'password');
    auth.logout();
    expect(auth.isAuthenticated()).toBeFalse();
    expect(localStorage.getItem('auth_token')).toBeNull();
  });
});

isAuthenticated is a signal, so call it like a function to read its value. You could swap localStorage with a stub, but in a browser-based runner, using the real localStorage is more natural and safer — just clear() neatly in beforeEach.

ProductsStore tests — the signal store #

The ProductsStore from post 4 was a signal-based state container. After a method call, just verify how the state changed by reading the signals.

src/app/products/products.store.spec.ts
describe('ProductsStore', () => {
  let store: ProductsStore;
  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [ProductsStore] });
    store = TestBed.inject(ProductsStore);
  });

  it('add appends a new product to the list', () => {
    store.add({ name: 'Keyboard', price: 90000, stock: 5 });
    expect(store.products().length).toBe(1);
    expect(store.products()[0].name).toBe('Keyboard');
  });

  it('totalValue is the sum of price × stock', () => {
    store.add({ name: 'A', price: 1000, stock: 2 });
    store.add({ name: 'B', price: 500, stock: 4 });
    expect(store.totalValue()).toBe(1000 * 2 + 500 * 4);
  });
});

The real value of a signal store is testability — and that shows up clearly here. No Observable subscriptions or async flows; every scenario is verified via plain function calls and result comparisons.

Component tests — the Login form #

The LoginComponent from post 2 had a Reactive Form. Form validation scenarios are most solid as component tests.

src/app/auth/login.component.spec.ts
describe('LoginComponent', () => {
  let fixture: ComponentFixture<LoginComponent>;
  let authSpy: jasmine.SpyObj<AuthService>;

  beforeEach(() => {
    authSpy = jasmine.createSpyObj<AuthService>('AuthService', ['login']);
    TestBed.configureTestingModule({
      imports: [LoginComponent, ReactiveFormsModule],
      providers: [{ provide: AuthService, useValue: authSpy }],
    });
    fixture = TestBed.createComponent(LoginComponent);
    fixture.detectChanges();
  });

  it('submitting empty inputs does not call login', () => {
    fixture.nativeElement.querySelector('form').dispatchEvent(new Event('submit'));
    expect(authSpy.login).not.toHaveBeenCalled();
  });

  it('valid inputs call login', () => {
    const el: HTMLElement = fixture.nativeElement;
    const username: HTMLInputElement = el.querySelector('[data-testid="username"]')!;
    const password: HTMLInputElement = el.querySelector('[data-testid="password"]')!;
    username.value = 'curtis';
    username.dispatchEvent(new Event('input'));
    password.value = 'secret123';
    password.dispatchEvent(new Event('input'));
    el.querySelector('form')!.dispatchEvent(new Event('submit'));
    expect(authSpy.login).toHaveBeenCalledOnceWith('curtis', 'secret123');
  });
});

Two key points — swap AuthService with a spy to isolate from real auth logic, and find form elements via data-testid to keep tests resilient to design changes.

Recapping HttpTestingController — the Product API #

The ProductApiService from post 3, if left to take real network, makes tests flaky. Pipe in fake responses with HttpTestingController.

src/app/products/product-api.service.spec.ts
describe('ProductApiService', () => {
  let service: ProductApiService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [ProductApiService, provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(ProductApiService);
    httpMock = TestBed.inject(HttpTestingController);
  });
  afterEach(() => httpMock.verify());

  it('GET /api/products emits an array of products', () => {
    const fake = [{ id: '1', name: 'Keyboard', price: 90000, stock: 5 }];
    service.list().subscribe((products) => expect(products).toEqual(fake));
    const req = httpMock.expectOne('/api/products');
    expect(req.request.method).toBe('GET');
    req.flush(fake);
  });

  it('falls back to an empty array on a 500 response', () => {
    service.list().subscribe((products) => expect(products).toEqual([]));
    httpMock.expectOne('/api/products')
      .flush('boom', { status: 500, statusText: 'Server Error' });
  });
});

The second case is where the real value lies — the 500-error fallback branch that wouldn’t normally fire is exercised by the test. A scenario nearly impossible to verify by hand without intentionally taking down the real server.

E2E tests — Playwright #

If unit/component tests examine individual parts, E2E tests look at the flow the user goes through. Angular’s official Protractor has long been deprecated; these days you reach for Playwright or Cypress. npm init playwright@latest installs the e2e/ folder and playwright.config.ts for you. You only need to add a webServer block to the config to auto-start the dev server — command: 'npm run start', url: 'http://localhost:4200', reuseExistingServer: !process.env.CI.

Now the first scenario — login → add product → chart updates.

e2e/dashboard.spec.ts
test('logging in and adding a product updates the chart', async ({ page }) => {
  await page.goto('/login');
  await page.getByTestId('username').fill('curtis');
  await page.getByTestId('password').fill('password');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);

  await page.getByRole('link', { name: 'Products' }).click();
  await page.getByRole('button', { name: 'New product' }).click();
  await page.getByTestId('product-name').fill('E2E Keyboard');
  await page.getByTestId('product-price').fill('120000');
  await page.getByTestId('product-stock').fill('3');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByText('E2E Keyboard')).toBeVisible();

  await page.getByRole('link', { name: 'Dashboard' }).click();
  await expect(page.getByTestId('total-value')).toContainText('360,000');
});

getByTestId and getByRole are Playwright’s real weapons — instead of feeling around the screen with CSS selectors, you grab elements by the user’s perspective and intent. When the design changes but the meaning doesn’t, the test doesn’t break.

Tip
E2E tests are the place to verify flow, not numbers. Usually one or two “smoke tests” — a single scenario that goes from login through the core feature in one shot — is the best bang for the buck. Think of unit/component tests as depth and E2E as breadth.

Environment variables — splitting dev and prod #

Before moving to deployment, one cleanup is needed — the API URL needs to be http://localhost:3000 in dev and https://api.example.com in prod.

src/environments/environment.ts / environment.prod.ts
// dev
export const environment = { production: false, apiUrl: 'http://localhost:3000' };
// prod
export const environment = { production: true, apiUrl: 'https://api.example.com' };

In angular.json’s production configuration, add fileReplacements.

angular.json
"configurations": {
  "production": {
    "fileReplacements": [
      { "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.prod.ts" }
    ],
    "optimization": true,
    "outputHashing": "all"
  }
}

Code only imports from one place — using environment.apiUrl as the base means ng build swaps in the prod file at production build time.

Note
Never put sensitive info (API keys, secrets) in environment files — they end up baked into the client bundle for anyone to see. Real secrets belong on the backend; the front end should only carry URLs/flags that are okay to be public as a rule.

Build and Dockerize #

Running ng build --configuration production drops the hashed index.html, main-*.js, and lazy chunks into dist/<project-name>/browser/ (Angular 17+). You can put this whole folder on static hosting, or to deploy identically across environments, package it as a Docker image — the build stage (Node) runs ng build, and the runtime stage (nginx) copies only the static files. Since the final image doesn’t carry Node, the size drops to less than half.

Dockerfile
# ---------- Build stage ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production

# ---------- Runtime stage ----------
FROM nginx:alpine
COPY --from=builder /app/dist/dashboard/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The piece commonly missed in SPAs is the 404 fallback. If you type a deep URL like /dashboard/products directly and refresh, nginx tries to find a file at that path and returns 404. All paths must route to index.html for the Angular router to handle them.

nginx.conf
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  location ~* \.(js|css|woff2|png|jpg|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
  location / { try_files $uri $uri/ /index.html; }
}

docker build -t dashboard .docker run --rm -p 8080:80 dashboard. The image size is typically within 30–40MB (nginx:alpine + static files).

Deployment — Cloudflare Pages #

The main deployment options are Vercel, Cloudflare Pages, and Netlify. All three offer static hosting + global CDN + generous free plans. I recommend Cloudflare Pages — fast builds, effectively no bandwidth limit, and the most reliable performance from Korea.

Connecting the GitHub repo takes a few clicks.

  1. Workers & Pages → Create application → Pages → Connect to Git
  2. Select the repo
  3. Build command: npm run build -- --configuration production
  4. Build output directory: dist/dashboard/browser
  5. Environment variables: NODE_VERSION=20

When a push happens, Cloudflare builds and deploys it for you. It also generates a preview URL automatically for each PR, making review much easier. For a SPA serving only static files, there’s little reason to reach for container hosting — Docker really shines when the frontend is bundled with a backend for deployment.

CI/CD — GitHub Actions #

The last piece. If you run tests and builds by hand on every push, you’ll inevitably miss one. Automate it with a single GitHub Actions file.

.github/workflows/ci.yml
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test -- --watch=false --browsers=ChromeHeadless
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - run: npm run build -- --configuration production

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build -- --configuration production
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}
          command: pages deploy dist/dashboard/browser --project-name=dashboard

The flow is clear — the test job runs unit tests, E2E tests, and the build all at once, and only after all of that passes does the deploy job run, restricted to the main branch. PRs trigger only the test job, not deploy; once merged into main, deployment kicks off.

secrets.CF_API_TOKEN and CF_ACCOUNT_ID are registered in the GitHub repo’s Settings → Secrets and variables → Actions. Set it once, and from then on you just git push.

Wrapping up — the practice track in retrospect #

That’s the last post of Angular in Practice, six posts. A short look back:

  • #1 Dashboard skeleton — Standalone + Router + layout
  • #2 Authentication flow — Reactive Form login, tokens, functional guards, interceptors
  • #3 Forms + API — complex form validation, HttpClient + Resource API
  • #4 State management — from a signal store to NgRx Signal Store
  • #5 UI library — the Angular Material design system and theming
  • #6 Testing and deployment — test coverage, Docker, Cloudflare Pages, GitHub Actions

Not a small demo — a single full cycle of logging in, fetching data, submitting a form, drawing charts, applying a design system, and deploying. You can think of these six posts as covering nearly all the decisions a fresh Angular project goes through in the field.

The Angular track, 27 posts — full retrospective #

That wraps up all 27 posts of the Angular track. Spread out at once, it looks like this:

  • Basics 7 posts — “Building screens with Angular” (What is Angular / Components / Data Binding / Directive,Pipe / Service,DI / Router / HttpClient)
  • Intermediate 7 posts — “Using Angular in the field” (Reactive Forms / RxJS / Lifecycle,CD / Interceptor / Lazy,Guards / SSR / Testing)
  • Advanced 7 posts — “Understanding Angular’s internals” (CD deep dive / Signals deep dive / RxJS deep dive / DI deep dive / SSR,Hydration / Microfrontends / Performance tuning)
  • Practice 6 posts — “One product, end to end” (Skeleton / Auth / Forms+API / State / UI / Testing and deployment)

Basics is the place to learn the names of the tools, intermediate is the patterns to solve daily problems with those tools, advanced is the time to peer inside those tools, and practice is the wrap-up where you assemble all of that in the context of one product. The single-line conclusion that runs across the 27 posts is that the combination of signal-based reactivity + Standalone + functional guards/interceptors + OnPush + lazy loading is the standard skeleton of modern Angular.

What stays in your hands looks like this — the instinct to make the first architectural decision without hesitation when a fresh Angular project starts, the judgment to pick the right tool for the situation among Reactive Forms, RxJS, and Signals, the ability to read and write Angular-flavored patterns like HTTP Interceptors and functional guards, and a development flow that includes testing and CI/CD from the start.

Recommended next steps #

The big picture for Angular itself closes here. Three branches that pay back well to invest time in next:

  1. Testing track — going deeper into Playwright and Vitest. Visual regression (Percy/Chromatic), Component Testing, MSW (Mock Service Worker), the test pyramid in real-world application. Once you’ve gone deep, it’s a skill that lasts a lifetime.
  2. Advanced state management — NgRx Signal Store, Component Store, RxAngular, TanStack Query for Angular. As the domain grows, knowing which tool fits which case — solidifying the decision in Practice #4.
  3. Backend side — the NestJS track (the Node.js framework whose design is closest to Angular’s). Familiar concepts like DI, decorators, and modules carry directly to the server. Once you’ve run a full-stack cycle hands-on, you start to see why the front-end’s decisions take the shapes they do.

The strongest next step is a small side project of your own. Take the tools you’ve learned across 27 posts and apply them to a small tool you actually want to use. One self-built 1,000-line app is worth far more than re-reading 27 posts.

Thank you sincerely for staying with the long track all the way through. Finishing any series is the hardest part, and you’ve done that 27 times over. See you in a new track — until then, hopefully we’ll meet again with a small app you’ve built yourself.

X