Refactoring Angular Components to use Model-View-Presenter Pattern

As software craftsmen, it’s often hard to pass on the art of writing software that’s easy to test. In the Angular community, the Angular.IO documentation does a pretty good job of providing examples of unit testing services. How do you test presentation logic well too?

https://angular.io/guide/testing-services

With my fellow developer friends who have schedule pressure, it’s natural and easy to create Angular components that have presentation logic that can become complex over time. In the XP and Agile community, we promote the idea of writing test automation(unit tests and integration tests) so that software teams have early warning sensors. Armed with a good unit test suite, teams can discover bugs or requirement inconsistencies faster. In order to grow a unit test suite, it takes time and discipline. Teams should aspire to find ways to increase their unit test coverage to services and presentation logic. What are the benefits of these practices and investments?

  • It’s easier to execute root cause analysis on bugs if good test automation practices are in place.
  • The craft of writing unit tests helps encourage the “single responsibility principle.” When a class or component takes on too much responsibility, it becomes harder for the team to change over time as complexity increases.
  • A good unit test suite becomes a tool to help communicate previous requirements and non-functional requirements.
  • In theory, your software design will probably implement SOLID principles which increases code re-use potential.

I’ve been searching for some good coaching material to explore the “model view presenter” (MVP) pattern with my teams. I found this really thoughtful post from Lars Gyrup and Brink Nielsen. I encourage you to give it a review before exploring the rest of this post.

https://dev.to/this-is-angular/model-view-presenter-with-angular-533h

I wrote this code sample to help me think through the higher level framework concepts mentioned by Lars and Brink. If you’d like to see the whole code example and run it, check out the following link.

https://github.com/michaelprosario/Sorter

Original code

While TDD works really well for many, I wanted to talk through a unit testing flow that described an incremental refactoring approach. As you’re learning a technology from blogs, StackOverflow, forum posts and a variety of sources, you won’t always find examples that think about the test engineering perspective. Can we incrementally refactor a code base to increase presentation logic coverage? I hope this post speaks to this question.

In this example, we’ll build a form that enables you to enter numbers to a list. The user will have the ability to sort the items in the list. While it’s not the most exciting demo application, the example shows us how we can execute refactoring to evolve the code into an MVP pattern.

<h1>Number Sorter</h1>

<input [(ngModel)]="strNumber" name="txtNumber">
<button (click)="onAddNumber()">Add Number</button>

<h3>List of numbers</h3>
<li *ngFor="let number of numberList">
   {{number}}
</li>

<button (click)="onSortList()">Sort Number List</button>

In SortEntry.component.ts, we can see that we’ve created typical coding patterns. For client programming, programmers find it natural to express logic oriented code in the component layer. Please make note of the “onAddNumber” and “bubbleSort” methods. In this post, we’ll explore ways to push these moments of logic into presenters or services. By encapsulating these items into presenter classes and services, we can unit test the inputs and outputs of the classes in an easier manner. This pattern also helps us avoid having to write unit test expectations or asserts that leverage DOM inspection.

import { Component, OnInit } from '@angular/core';

@Component({
 selector: 'app-sorter-entry',
 templateUrl: './sorter-entry.component.html',
 styleUrls: ['./sorter-entry.component.css']
})
export class SorterEntryComponent implements OnInit {

 constructor() { }

 strNumber: string = "";
 numberList: Array<number> = [];

 isNumeric(value: string) {
   return /^-?\d+$/.test(value);
 } 

 onAddNumber(){
   if(this.isNumeric(this.strNumber)){
     let intNumber: number = parseInt(this.strNumber);
     this.numberList.push(intNumber);
     this.strNumber = ""; 
   }else{
     alert("Input should be a number");
   }
 }

 bubbleSort(inputArray: Array<number>) {
   if(!inputArray){
     return [];
   }

   let len = inputArray.length;
   let swapped;
   do {
       swapped = false;
       for (let i = 0; i < len; i++) {
           if (inputArray[i] > inputArray[i + 1]) {
               let tmp = inputArray[i];
               inputArray[i] = inputArray[i + 1];
               inputArray[i + 1] = tmp;
               swapped = true;
           }
       }
   } while (swapped);

   return inputArray;
 }; 

 onSortList(){
   this.numberList = this.bubbleSort(this.numberList);
 }

 ngOnInit(): void {
 }

}

Refactor sorting logic into a service

In our first refactoring, we’ll encapsulate the sorting logic into a service. Please take note that the bubbleSort and swapItems methods have become public methods.

import { Injectable } from '@angular/core';
import { Ensure } from '../helpers/ensure';

@Injectable({
 providedIn: 'root'
})
export class NumberSortService {

 constructor() { }

 bubbleSort(inputArray: Array<number>) {
   Ensure.thatObjectNotNull(inputArray, "input array is required");

   if(!inputArray){
     return [];
   }

   let len = inputArray.length;
   let swapped;
   do {
       swapped = false;
       for (let i = 0; i < len; i++) {
           if (this.itemsOutOfOrder(inputArray, i)) {
               this.swapItems(inputArray, i);
               swapped = true;
           }
       }
   } while (swapped);

   return inputArray;
 }; 

 private itemsOutOfOrder(inputArray: number[], i: number) {
   return inputArray[i] > inputArray[i + 1];
 }

 swapItems(inputArray: number[], i: number) {
   Ensure.thatObjectNotNull(inputArray, "input array is required");
   Ensure.thatObjectNotNull(i, "i is required");

   let tmp = inputArray[i];
   inputArray[i] = inputArray[i + 1];
   inputArray[i + 1] = tmp;
 }
}

With the NumberSortService in place, we can now unit test the service. We make sure the sort method actually works with sample data. We also test the lower bounds of the sort service. Since the sort algorithm depends upon swapping elements, we have tested that idea too.

import { NumberSortService } from './number-sort.service';
...

describe('NumberSortServiceService', () => {

 describe('bubbleSort', () => {
   it('should sort numbers correctly', () => {
     // arrange
     let myList: Array<number> = [10,8,6,4,2];

     // act
     service.bubbleSort(myList);

     // assert
     expect(myList).toEqual([2,4,6,8,10]);
   });

   it('should sort numbers correctly on 2 element array', () => {
     // arrange
     let myList: Array<number> = [10,8];

     // act
     service.bubbleSort(myList);

     // assert
     expect(myList).toEqual([8,10]);
   });   

   it('should handle 1 element array', () => {
     // arrange
     let myList: Array<number> = [10];

     // act
     service.bubbleSort(myList);

     // assert
     expect(myList).toEqual([10]);
   }); 

   it('should throw error if array is empty', () => {
     // arrange
     let myList: Array<number> = [];

     // act
     service.bubbleSort(myList);

     // assert     
     expect(myList).toEqual([]);
   });    
 });

 describe('swap', () => {
   it('should swap numbers correctly', () => {
     // arrange
     let myList: Array<number> = [10,8,6,4,2];

     // act
     service.swapItems(myList,0);

     // assert
     expect(myList).toEqual([8,10,6,4,2]);
   });

   it('should swap numbers correctly again', () => {
     // arrange
     let myList: Array<number> = [10,8,6,4,2];

     // act
     service.swapItems(myList,3);

     // assert
     expect(myList).toEqual([10,8,6,2,4]);
   });   

   it('should handle bad array', () => {
     // arrange
     let myList: Array<number> = [];

     // act      
     expect( () => { service.swapItems(myList,0) } ).toThrow(new Error("Input array should have at least 2 elements"));
   });     

   it('should handle i value larger than array', () => {
     // arrange
     let myList: Array<number> = [10,8,6,4,2];

     // act
     // assert
     expect( () => { service.swapItems(myList,10); } ).toThrow(new Error("i should be inside the bounds of the array"));
     expect( () => { service.swapItems(myList,5); } ).toThrow(new Error("i should be inside the bounds of the array"));
   });

 });

}); 

In the same folder as the “sort entry component”, I created a presenter class and started migrating any method that has logic in the original component into the presenter class. The presenter has two methods for checking for numeric strings and logic for adding numbers to the list. From a design point of view, presenter classes should focus on encapsulating the logic of the presentation layer.


import { Ensure } from "src/app/core/helpers/ensure"; import { ISorterEntryView } from "./sorter-entry-view"; export class SorterEntryPresenter { constructor(private view: ISorterEntryView){ Ensure.thatObjectNotNull(view, "view is required"); } isNumeric(value: string) { Ensure.thatObjectNotNull(value, "value is required"); return /^-?\d+$/.test(value); } onAddEntry(strNumber: string , numberList: Array<number>){ Ensure.thatObjectNotNull(strNumber, "strNumber is required"); Ensure.thatObjectNotNull(numberList, "numberList is required"); if(this.isNumeric(strNumber)){ let intNumber: number = parseInt(strNumber); let outputList = [...numberList]; outputList.push(intNumber); this.view.setNumberList(outputList); }else{ this.view.showErrorMessage("Input should be a number"); } } }

At some point the presentation logic needs to call back to the actual view component. Since presenter classes have the responsibility of encapsulating presentation logic, they should avoid the responsibility of doing any UI framework specific code. In this case, the interface defines high level methods that need to be implemented by the presentation Angular component.

export interface ISorterEntryView
{
 setNumberList(outputList: number[]): void;
 showErrorMessage(errorMessage: string): void;
}

The “sort entry component” will implement the “ISorterEntryView” interface enabling the presentation logic to call back into the the component. All logic has been removed from the component. You might say this component has become dumber. 🙂

import { Component, OnInit } from '@angular/core';
import { NumberSortService } from 'src/app/core/services/number-sort.service';
import { ISorterEntryView } from './sorter-entry-view';
import { SorterEntryPresenter } from './sorter-entry.presenter';

...
export class SorterEntryComponent implements OnInit, ISorterEntryView {

 strNumber: string = "";
 numberList: Array<number> = [];
 presenter: SorterEntryPresenter;

 constructor(private numberSortService: NumberSortService) {
   this.presenter = new SorterEntryPresenter(this);
 }
  setNumberList(outputList: number[]) {
   this.numberList = outputList;
   this.strNumber = "";
 }

 showErrorMessage(errorMessage: string): void {
   alert(errorMessage);
 }

 onAddNumber(){
   this.presenter.onAddEntry(this.strNumber, this.numberList);
 }

 onSortList(){
   this.numberList = this.numberSortService.bubbleSort(this.numberList);
 }

 ...

}

Now, let’s add some tests of the presentation logic.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ISorterEntryView } from './sorter-entry-view';
import { SorterEntryComponent } from './sorter-entry.component';
import { SorterEntryPresenter } from './sorter-entry.presenter';
import { Substitute, Arg } from '@fluffy-spoon/substitute';

describe('SorterEntryComponent', () => {
  let component: SorterEntryComponent;
  let fixture: ComponentFixture<SorterEntryComponent>;  

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [SorterEntryComponent]
    })
      .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(SorterEntryComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();    
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('NumberSorterPresenter', () => {

    it('#isNumeric should return true on 123', () => {
      const view = Substitute.for<ISorterEntryView>();
      let presenter = new SorterEntryPresenter(view);

      // arrange
      let input = "123";

      // act
      let output = presenter.isNumeric(input);

      // assert
      expect(output).toBeTruthy();
    });

    it('#isNumeric should return false on dog', () => {
      const view = Substitute.for<ISorterEntryView>();
      let presenter = new SorterEntryPresenter(view);

      // arrange
      let input = "dog";

      // act
      let output = presenter.isNumeric(input);

      // assert
      expect(output).toBeFalsy();
    });

    it('#isNumeric should return false on empty string', () => {
      const view = Substitute.for<ISorterEntryView>();
      let presenter = new SorterEntryPresenter(view);

      // arrange
      let input = "";

      // act
      let output = presenter.isNumeric(input);

      // assert
      expect(output).toBeFalsy();
    });

    it('#isNumeric should return false on empty string', () => {
      const view = Substitute.for<ISorterEntryView>();
      let presenter = new SorterEntryPresenter(view);

      // arrange
      let input = "";

      // act
      let output = presenter.isNumeric(input);

      // assert
      expect(output).toBeFalsy();
    });

    it('#onAddEntry should work for happy case', () => {      
      const view = Substitute.for<ISorterEntryView>();
      let presenter = new SorterEntryPresenter(view);

      // arrange      
      let list: Array<number> = [];

      // act
      presenter.onAddEntry("2", list)

      // assert            
      view.didNotReceive().showErrorMessage(Arg.any());
      view.received().setNumberList(Arg.any());      
    });

    it('#onAddEntry should work for bad input', () => {
      const view = Substitute.for<ISorterEntryView>();
      let presenter = new SorterEntryPresenter(view);

      // arrange
      let input = "bad data";
      let list: Array<number> = [];

      // act
      presenter.onAddEntry(input, list)

      // assert
      view.received().showErrorMessage(Arg.any());
      view.didNotReceive().setNumberList(Arg.any());      
    });

  });
});

I wanted to give a shout out to a mocking framework that I’ve enjoyed using called Substitute. I appreciate how lightweight it can be to make methods on dependencies (i.e. ISorterEntryView) return different values as needed in a unit test. It also has a mechanism to assert if dependency methods were called.

npm install @fluffy-spoon/substitute --save-dev
it('#onAddEntry should work for happy case', () => {      
    const view = Substitute.for<ISorterEntryView>();
    let presenter = new SorterEntryPresenter(view);

    // arrange      
    let list: Array<number> = [];

    // act
    presenter.onAddEntry("2", list)

    // assert            
    view.didNotReceive().showErrorMessage(Arg.any());
    view.received().setNumberList(Arg.any());      
});

it('#onAddEntry should work for bad input', () => {
    const view = Substitute.for<ISorterEntryView>();
    let presenter = new SorterEntryPresenter(view);

    // arrange
    let input = "bad data";
    let list: Array<number> = [];

    // act
    presenter.onAddEntry(input, list)

    // assert
    view.received().showErrorMessage(Arg.any());
    view.didNotReceive().setNumberList(Arg.any());      
});

Hope this example helps you in understanding the flow of the “model / view / presenter” pattern in Angular. Please check out the following links for more context and examples

References

Be the first to comment

Leave a Reply

Your email address will not be published.


*