One Keyword to Help You Test...and Digest, Your Angular Logic

Share
One Keyword to Help You Test...and Digest, Your Angular Logic

The problem: Staying Motivated to Learn the Business

You just got hired as a software developer for a toilet seat company (yes, somehow in 2025 the logistics are too complex to automate or outsource at this particular company...plus the job market is tough out there). You've confided in your software dev mentors over the years with messages like these drilled into you: 'Know your components', 'know the flows', 'know the business'...'spend x hours every weekend studying it...it's your job!' Three small problems:

  1. The product you're working on is boring as hell
  2. You're not that bonafide, hardcore nerd who craves knowing every nook and cranny of how the business, any business works. (Enter sanctimonious 30 year dev: "You should be excited to solve any problem I drop on your lap!")
  3. We're talking about highly subjective measures (business domain expertise), with ambiguous and delayed rewards

Translation: you can't stick with your "learning" habit for 3 straight weeks and you're resorting to Tony Robbins and Jordan Peterson YouTube videos to keep your ass disciplined enough to learn that toilet seat supply chain dashboard (we both know damn well you'd be reading that thing like the Bible if you had a story on the dashboard)

Solution : Killer Unit Tests (stay tuned for the keyword)

Unit testing...the most boring part of software development, on a toilet seat application? You must be kidding, right? Before you get mad: here are a few things I've learned from doing this for 7 years:

  • Good tests are forcing you to understand what's going on, there's no getting around it; if I see a component with a new feature added, that feature touches two other features on the component, and excellent tests were written for all those feature, I know you know the features, you know I know if I read the PR, you could describe all the features to a stranger if you were drunk. This is something I think you can actually stick with
  • Testing is highly interactive, and highly creative if it's done with a business mindset, and can be perfected such that you are telegraphing to yourself, and to others, this is the feature being tested, you're more likely to stick with it. Interactive, creative things are much easier to stay motivated with than sitting in front of the computer and almost rewriting confluence documents on features in your notebook
  • Spend enough hours writing tests, on five or more projects, the technical tools to test the most annoyingly hard-to-test code will become easy (and if you're one of the lucky ones who have been asked to do 100% coverage, we have our friend AI to cut that R&D time at least in half).
  • As the descriptions become more...descriptive, it becomes a game. How little could someone looking at these test descriptors read to know what the component is doing? 18, 15, 12 years old?

"Cort, what are you talking about with 'business' mindset', 'descriptive describes', 'telegraphing'? I have a method that takes in some numbers and spits back an average with some string formatting, how many ways can I describe something so simple? This isn't rocket science."

Me ^

I'm glad you asked...

Testing for features, not implementation. Method chaining tests. And, the much awaited keyword: Private.

To answer your "simple description" question, no, we're not talking about one method, I'm talking about multiple, and that multiple is going to shift your thinking...

I'm going to introduce a concept, and if you came up angular, not an OOP server-side language, you probably were never taught this (I say this presumptively because I basically never seen it done on any of the angular projects I've been assigned). When I test a group of methods together, by only calling the method that call the other methods (for all intents and purposes this is likely an event in an angular app), and the final method called gives me an outcome (e.g. something on the page becomes visible, and service is called, or a different page is navigated to), the test doesn't just represent a bunch of little pieces of a functionality, they test the methods together to verify the actual functionality. This is where you actually start learning what the component (or service) is doing and teach your coworkers about it in the process.

Check out this Employee Leave Approval Component, but first...

Just to have the right mindset (and we’ll get back to why later), I’m going to point out a few things:

onLeaveRequested is a method triggered by clicking a button in the html (hence why we gave it a name starting with "on"), this sets all the other methods in motion

executeApprovalOutcome produces an outcome, ultimately there are three outcomes, an empty/non-update leave message, a 'success' leave message, and a 'fail' leave message. Assuming no errors are thrown, all flows end up with this method being called

processLeaveRequest, createRejection, calculateDays, and calculateNotice are the 'meat' of the code, the business logic, everything that happens between the event and the final method which returns the outcome.

export class LeaveApprovalComponent {
  currentLeave: ProcessedLeave | null = null;

  readonly MAX_CONSECUTIVE_DAYS = 14;
  readonly MIN_NOTICE_DAYS = 7;
  leaveMessage: string = '';

  onLeaveRequested(request: LeaveRequest): void {
    console.log('Leave request submitted:', request.requestId);
    this.currentLeave = this.processLeaveRequest(request);
    this.executeApprovalOutcome(this.currentLeave);
  }

  processLeaveRequest(request: LeaveRequest): ProcessedLeave {
    const daysRequested = this.calculateDays(request.startDate, request.endDate);

    if (daysRequested > this.MAX_CONSECUTIVE_DAYS) {
      return this.createRejection(
        request,
        daysRequested,
        'Exceeds 14-day limit'
      );
    }

    const noticeGiven = this.calculateNotice(request.startDate);
    if (noticeGiven < this.MIN_NOTICE_DAYS) {
      return this.createRejection(
        request,
        daysRequested,
        'Insufficient advance notice'
      );
    }

    return {
      ...request,
      status: 'approved',
      daysRequested
    };
  }

  executeApprovalOutcome(leave: ProcessedLeave): void {
    if (leave.status === 'approved') {
      this.leaveMessage = `Leave approved for ${leave.employeeName} (${leave.daysRequested} days)`;
    } else {
      this.leaveMessage = `Leave rejected: ${leave.rejectionReason}`;
    }
  }

  calculateDays(start: Date, end: Date): number {
    return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
  }

  calculateNotice(startDate: Date): number {
    return Math.ceil(
      (startDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
    );
  }

  createRejection(
    request: LeaveRequest,
    days: number,
    reason: string
  ): ProcessedLeave {
    return {
      ...request,
      status: 'rejected',
      rejectionReason: reason,
      daysRequested: days
    };
  }
}

Your everyday, run-of-the-mill Angular developer, for example, will take this block of code

executeApprovalOutcome(leave: ProcessedLeave): void {
  if (leave.status === 'approved') {
    this.leaveMessage = `Leave approved for ${leave.employeeName} ` +
                        `(${leave.daysRequested} days)`;
  } else {
    this.leaveMessage = `Leave rejected: ${leave.rejectionReason}`;
  }
}

...and will naturally write a test case like this:

it('should set approval message when leave is approved', () => {
      const approvedLeave: ProcessedLeave = {
        requestId: 'LR-TEST-001',
        employeeId: 'EMP-TEST',
        employeeName: 'John Doe',
        leaveType: 'vacation',
        startDate: new Date(),
        endDate: new Date(),
        reason: 'Test vacation',
        status: 'approved',
        daysRequested: 5
      };

      component.executeApprovalOutcome(approvedLeave);

      expect(component.leaveMessage).toBe('Leave approved for John Doe (5 days)');
    });

That will get you your coverage, but, by testing this and all the other methods like this in isolation, there's a missed opportunity to fully capture the essence of what the component is doing. I do not want to put a bunch of puzzle pieces on the floor in my mind to figure out what's happening, I want large chunks of puzzle pieces assembled, so I can see parts of something bigger!

Your typical Unit Test: I know very little about the feature from looking at this
Two Tests testing Two Outcomes of a Feature, not I'm starting to see the bigger picture

Ok, where do we start? Let's observe the code, and piece together a happy path to make our unit tests (happy path is always the best place to start)

onLeaveRequested always calls processLeaveRequested, which always calls calculateDays, and will only call createRejection if the 14 day leave time limit is reached, less than 7 days notice is given, so let's make sure those conditions aren't true, the request information is returned from processLeaveRequested and the request info is passed to executeApprovalOutcome, which sets the value of the display information i.e., leaveMessage. Let's trim down the code so you see only what we're interested in testing.


onLeaveRequested(request: LeaveRequest): void {
  console.log('Leave request submitted:', request.requestId);
  this.currentLeave = this.processLeaveRequest(request);
  this.executeApprovalOutcome(this.currentLeave);
}

processLeaveRequest(request: LeaveRequest): ProcessedLeave {
  const daysRequested = this.calculateDays(
    request.startDate,
    request.endDate
  );

  // Ensure this is false for happy path
  if (daysRequested > this.MAX_CONSECUTIVE_DAYS) {
    // Not testing this yet, not interested
  }

  // Ensure this is false for happy path
  const noticeGiven = this.calculateNotice(request.startDate);
  if (noticeGiven < this.MIN_NOTICE_DAYS) {
    // Not testing this yet, not interested
  }

  return {
    ...request,
    status: 'approved',
    daysRequested,
  };
}

executeApprovalOutcome(leave: ProcessedLeave): void {
  // Ensure this is true for happy path
  if (leave.status === 'approved') {
    this.leaveMessage = `Leave approved for ${leave.employeeName} ` +
                        `(${leave.daysRequested} days)`;
  } else {
    // Not testing this yet, not interested
  }
}

Here is our test case, calling only the event, and verifying the variable updated in the last function in the stack (leaveMessage)

it('should approve leave if 5 days in advance was provided and the leave time ' +
   'does not exceed 14 consecutive days', () => {

  const daysInAdvance = 10;
  const leaveDuration = 5;

  const leaveDay1 = new Date(
    Date.now() + daysInAdvance * 24 * 60 * 60 * 1000
  );

  const leaveDay5 = new Date(
    leaveDay1.getTime() + (leaveDuration - 1) * 24 * 60 * 60 * 1000
  );

  const leaveRequest: LeaveRequest = {
    requestId: 'LR-TEST-001',
    employeeId: 'EMP-TEST',
    employeeName: 'John Doe',
    leaveType: 'vacation',
    startDate: leaveDay1,
    endDate: leaveDay5,
    reason: 'Test vacation',
  };

  component.onLeaveRequested(leaveRequest);

  expect(component.leaveMessage).toBe(
    'Leave approved for John Doe (5 days)'
  );
});

We referenced only two methods, and I'm not going to lie, I didn't even understand what the code was doing until I actually started working on the tests in making this article, and in doing so, described it to you. I just gave you the long and short of what this component is doing. Since we did the legwork of the data setup in the last test, can easily copy this and update leaveDuration to test a different branch (aka condition) in the code:

it('should reject requested leave time because 14-day limit ' +
   'is exceeded', () => {

  const daysInAdvance = 10;
  const leaveDuration = 15;

  const leaveDay1 = new Date(
    Date.now() + daysInAdvance * 24 * 60 * 60 * 1000
  );

  const leaveDay5 = new Date(
    leaveDay1.getTime() + (leaveDuration - 1) * 24 * 60 * 60 * 1000
  );

  const leaveRequest: LeaveRequest = {
    requestId: 'LR-TEST-001',
    employeeId: 'EMP-TEST',
    employeeName: 'John Doe',
    leaveType: 'vacation',
    startDate: leaveDay1,
    endDate: leaveDay5,
    reason: 'Test vacation',
  };

  component.onLeaveRequested(leaveRequest);

  expect(component.leaveMessage).toBe(
    'Leave rejected: Exceeds 14-day limit'
  );
});

Okay, the article is about a keyword—you know it's 'Private'—but why?

🔐
Implementing "Private" on non-event triggered methods forces you to make tests with chaining, when you're tempted to just cover that one-and-done method and you're feeling lazy 🙂

Here's the updated code with private:

export class LeaveApprovalComponent {
  currentLeave: ProcessedLeave | null = null;

  private readonly MAX_CONSECUTIVE_DAYS = 14;
  private readonly MIN_NOTICE_DAYS = 7;

  leaveMessage: string = '';

  onLeaveRequested(request: LeaveRequest): void {
    console.log('Leave request submitted:', request.requestId);
    this.currentLeave = this.processLeaveRequest(request);
    this.executeApprovalOutcome(this.currentLeave);
  }

  private processLeaveRequest(request: LeaveRequest): ProcessedLeave {
    const daysRequested = this.calculateDays(
      request.startDate,
      request.endDate
    );

    if (daysRequested > this.MAX_CONSECUTIVE_DAYS) {
      return this.createRejection(
        request,
        daysRequested,
        'Exceeds 14-day limit'
      );
    }

    const noticeGiven = this.calculateNotice(request.startDate);

    if (noticeGiven < this.MIN_NOTICE_DAYS) {
      return this.createRejection(
        request,
        daysRequested,
        'Insufficient advance notice'
      );
    }

    return {
      ...request,
      status: 'approved',
      daysRequested,
    };
  }

  private executeApprovalOutcome(leave: ProcessedLeave): void {
    if (leave.status === 'approved') {
      this.leaveMessage = `Leave approved for ${leave.employeeName} ` +
                          `(${leave.daysRequested} days)`;
    } else {
      this.leaveMessage = `Leave rejected: ${leave.rejectionReason}`;
    }
  }

  private calculateDays(start: Date, end: Date): number {
    return Math.ceil(
      (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)
    ) + 1;
  }

  private calculateNotice(startDate: Date): number {
    return Math.ceil(
      (startDate.getTime() - new Date().getTime()) /
      (1000 * 60 * 60 * 24)
    );
  }

  private createRejection(
    request: LeaveRequest,
    days: number,
    reason: string
  ): ProcessedLeave {
    return {
      ...request,
      status: 'rejected',
      rejectionReason: reason,
      daysRequested: days,
    };
  }
}

You can't reference the methods anymore (Okay, not entirely true—but don’t even think of using this hack, or you and your coworkers will abandon the process)

const days = (component as any)['calculateDays'](startDate, endDate);

Ok, what about methods with tricky edge cases?

This is usually doable. It’s totally fine to write tests where two or more paths lead to the same outcome, but different means of getting there. Let's add a branch (or condition) to one of our methods, to handle a null start date (Not saying this is the most elegant way to handle nulls).

private calculateDays(start: Date, end: Date): number {
  if (start === null) {
    // Highest possible timestamp in JavaScript
    return new Date(8640000000000000).getTime();
  }

  return Math.ceil(
    (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)
  ) + 1;
}


  
  private processLeaveRequest(request: LeaveRequest): ProcessedLeave {
    const daysRequested = this.calculateDays(
      request.startDate,
      request.endDate
    );
 
    // This will now evaluate to false
    if (daysRequested > this.MAX_CONSECUTIVE_DAYS) {
      return this.createRejection(
        request,
        daysRequested,
        'Exceeds 14-day limit'
      );
    }

    const noticeGiven = this.calculateNotice(request.startDate);

    if (noticeGiven < this.MIN_NOTICE_DAYS) {
      return this.createRejection(
        request,
        daysRequested,
        'Insufficient advance notice'
      );
    }

    return {
      ...request,
      status: 'approved',
      daysRequested,
    };
  }

Now, the test case:

it('should reject leave and explain 14-day limit exceeded if start time is ' +
   'omitted from request', () => {

  const leaveDuration = 15;

  // Simulate a fallback or placeholder base date if startDate is null
  const fallbackStart = new Date();

  const leaveDay15 = new Date(
    fallbackStart.getTime() + (leaveDuration - 1) * 24 * 60 * 60 * 1000
  );

  const leaveRequest: LeaveRequest = {
    requestId: 'LR-TEST-002',
    employeeId: 'EMP-TEST',
    employeeName: 'Jane Doe',
    leaveType: 'vacation',
    startDate: null,
    endDate: leaveDay15,
    reason: 'Long vacation',
  };

  component.onLeaveRequested(leaveRequest);

  expect(component.leaveMessage).toBe(
    'Leave rejected: Exceeds 14-day limit'
  );
});

This is a LOT of time thinking out these elaborate test cases.

You're not screwed if management mandates 100% coverage and it’s the night before the big code freeze and release management has been talking about, and your Jenkins build fails because you're missing coverage on your branch, there are exceptions, there are extenuating circumstances. Just remember, more use of private is better—it helps you and your code readers understand more clearly, and your code readers understand: As with many things in life, you get out what you put in. Also, do you trust your team—and yourself—to really say "Only once in a while"? This kind of detail is the stuff that backend engineers, the ones that get hired full time as seniors hired at big companies, the tedious, low-key, yet beneficial processes that pay dividends in the long run: refactoring, TDD, code smells, and SOLID principles.

💡
Final Note: You are introducing the HUMAN element of testing as you and your coworkers become more comfortable with the business—and you can show them this benefit—this (and perhaps you?) becomes much harder to automate. 😏

Enjoyed this breakdown? Subscribe for more no-fluff Angular testing insights.

Subscribe