Angular Testing

From bibbleWiki
Jump to navigation Jump to search

Jasmine

Simple Example

Angular by default using Jasmine for testing. Test follow the same approach as other frameworks. You can use pending and fail to simulate pending and guess what, failing.

describe('CalculatorService', ()=> {

    it('Should add two numbers', () => {
        const logger = new LoggerService()
        spyOn(logger, 'log')

        const calculator  = new CalculatorService(logger)
        const result = calculator.add(2,2)
        expect(result).toBe(4)
        expect(logger.log).toHaveBeenCalledTimes(1)
    });

})

Spies

These allow you to track the things about the objects. In this simple example we are wanting to track how many time the function log was called on the LoggerService.

describe('CalculatorService', ()=> {

    it('Should add two numbers', () => {
        const logger = new LoggerService()
        spyOn(logger, 'log')

        const calculator  = new CalculatorService(new LoggerService())
        const result = calculator.add(2,2)
        expect(result).toBe(4)

        expect(logger.log).toHaveBeenCalledTimes(1)
    });

})

In reality we should not use dependant services because the error could be in the logger service and not the one being tested. So we need to create a mock of the logger service using a SpyObject. Spy Objects are automatically spied on. We also use the beforeEach to simplify the test

    let calculator: CalculatorService
    let loggerSpy: any

    beforeEach(()=> {
        calculator  = new CalculatorService(new LoggerService())
        loggerSpy = jasmine.createSpyObj('LoggerService', ["log"])
    })

    it('Should add two numbers', () => {
        const result = calculator.add(2,2)
        expect(result).toBe(4)
        expect(loggerSpy.log).toHaveBeenCalledTimes(1)
    });
...
    it('Should add two numbers', () => {
        const logger = jasmine.createSpyObj('LoggerService', ["log"])
        const calculator  = new CalculatorService(new LoggerService())
        const result = calculator.add(2,2)
        expect(result).toBe(4)

        expect(logger.log).toHaveBeenCalledTimes(1)
    });
})

Dependency Injection

In the above example we call new on the CalculatorService. The better way to do this is to use the TestBed.configureTestingModule where we use a similar approach to the running code. We specify the class being tested as a provider from TestBed and we use our mocked versions of the logger to ensure we are ONLY testing the service.

    beforeEach(()=> {
        loggerSpy = jasmine.createSpyObj('LoggerService', ["log"])

        TestBed.configureTestingModule({
            providers: [
                CalculatorService,
                {provide: LoggerService, useValue: loggerSpy}
            ]
        })

        calculator = TestBed.inject(CalculatorService)
    })

Example Testing HTTP Requests

Here is an example of testing the HTTP module from angular. Angular comes with its own testing client so this is fairly simple. The example shows a typical use of subscribe. Below is the method to be tested.

    findAllCourses(): Observable<Course[]> {
        return this.http.get('/api/courses')
            .pipe(
                map(res => res['payload'])
            );
    }

We setup the http testing client

    beforeEach(()=> {

        TestBed.configureTestingModule({
            imports: [HttpClientTestingModule],
            providers: [
                CoursesService,
            ]
        })

        coursesService = TestBed.inject(CoursesService)
        httpTestingController = TestBed.inject(HttpTestingController)
    })

Now we can write our tests. It is only when flush is called that the subscribe part of the test is run.

    it('should retrieve all course',()=> {

        coursesService.findAllCourses().subscribe(
            courses => {
                // Not undefined, NULL
                expect(courses).toBeTruthy()

                // length of array returned
                expect(courses.length).toBe(12)

                // Get a course to examine
                const course = courses.find(course => course.id == 12)

                // Test a property
                expect(course.titles.description).toBe("Angular Testing Course")
            })

        const req = httpTestingController.expectOne('/api/courses')
        expect(req.request.method).toEqual("GET")
        req.flush({payload: Object.values(COURSES)})
    })

At the end of each request we call verify on the controller. This ensures no unmatched requests are outstanding.

    afterEach(()=>{
        httpTestingController.verify()
    })

Here is an example of testing an error. We deliberately flush with a status:500. Note that we fail the test if the next() function is called.

    it('should give an error if save course fails', () => {

        const changes :Partial<Course> = {titles:{description: 'Testing Course'}};

        coursesService.saveCourse(12, changes)
            .subscribe({
                next:() => fail("the save course operation should have failed"),

                error:(error: HttpErrorResponse) => {
                    expect(error.status).toBe(500);
                }
            });

        const req = httpTestingController.expectOne('/api/courses/12');

        expect(req.request.method).toEqual("PUT");

        req.flush('Save course failed', {status:500,
            statusText:'Internal Server Error'});
    });

Tip

Putting "f" before the "it" or "describe" means on that test case/test suite will run, putting "x" e.g. xdescribe, xit will exclude this test case/test suite.

Setting up Karma

This took a lifetime to set up and make me wonder if I am using the right tool. It comes with Angular to stayed with it. What took the time was setting up the console.log to work. Here is my karma.conf.js.

module.exports = function (config) {
  config.set({
    basePath: "",
    frameworks: ["jasmine", "@angular-devkit/build-angular"],
    plugins: [
      require("karma-jasmine"),
      require("karma-chrome-launcher"),
      require("karma-jasmine-html-reporter"),
      require("karma-coverage-istanbul-reporter"),
      require("karma-coverage"),
      require("@angular-devkit/build-angular/plugins/karma"),
    ],
    client: {
      clearContext: false, // leave Jasmine Spec Runner output visible in browser
    },
    browserConsoleLogOptions: {
      terminal: true,
      level: ""
    },    
    jasmineHtmlReporter: {
      suppressAll: true // removes the duplicated traces
    },
    coverageIstanbulReporter: {
      dir: require("path").join(__dirname, "./coverage/debug-tests"),
      reports: ["html", "lcovonly", "text-summary"],
      fixWebpackSourcePaths: true,
    },
    coverageReporter: {
      dir: require('path').join(__dirname, './coverage/bibble-park-app'),
      subdir: '.',
      reporters: [
        { type: 'html' },
        { type: 'text-summary' }
      ]
    },
    reporters: ["progress", "kjhtml"],
    port: 9876,
    colors: true,
    logLevel: config.LOG_DEBUG,
    autoWatch: true,
    browsers: ["Chrome"],
    singleRun: false,
    restartOnFileChange: true,
  });
};

RxJs Testing

Decided to look a bit more into how to test observables. My starting point is Kevin Kreuzer's Medium page here.
The RxJs team provides and approach where you define a scheduler and a RunHelper

interface RunHelpers {
  cold: typeof TestScheduler.prototype.createColdObservable
  hot: typeof TestScheduler.prototype.createHotObservable
  flush: typeof TestScheduler.prototype.flush
  time: typeof TestScheduler.prototype.createTime
  expectObservable: typeof TestScheduler.prototype.expectObservable
  expectSubscriptions: typeof TestScheduler.prototype.expectSubscriptions
  animate: (marbles: string) => void
}

Let's define the code we are testing which is function which filters out odd numbers and multiplies the even ones by 10.

const evenTimesTen = (source$: Observable<number>): Observable<number> => {
  return source$.pipe(
    filter((n) => n % 2 === 0),
    map((n) => n * 10),
  )
}

Simple Test

Then a define the test case like any Jasmine case.

describe('ExampleRxjsTest', () => {
  let scheduler: TestScheduler

  beforeEach(() => {
    scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected)
    })
  })

  it('should filter out odd numbers', () => {
    scheduler.run((runhelpers: RunHelpers) => {...})
  })

})

The author suggested that destructuring the interface implementation makes it a log easier to read. In this example we will define code and expectObservable.

/*
  it('should filter out odd numbers', () => {
    scheduler.run((runhelpers: RunHelpers) => {...})
  })
*/

  it('should filter out odd numbers', () => {
    scheduler.run(({ cold, expectObservable }) => {
      const values = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }
      const source$ = cold('a-b-c-d-e-f|', values)
      const expectedMarble = '--a---b---c|'
      const expectedValues = { a: 20, b: 40, c: 60 }
      const result$ = evenTimesTen(source$)
      expectObservable(result$).toBe(expectedMarble, expectedValues)
    })
  })

Testing with an Error

I like this because I found my trusted divide by zero does not work with TS/Javascript. Much the same as above but this time we are testing for an error. Here is the function

const tenDivideByValue = (
  source$: Observable<number>,
): Observable<number> | Observable<unknown> => {
  return source$.pipe(
    map((n) => {
      const result = 10 / n
      console.log(`Value we got was ${result}`)
      if (!isFinite(result)) {
        throw new Error('Divide by zero')
      } else {
        return result
      }
    }),
  )
}

And here is the test case. The tricky part was understanding errorvalue meant an instance of Error.

  it('should fail on divide by zero', () => {
    const errorValue = new Error('Divide by zero')

    scheduler.run(({ cold, animate, expectObservable }) => {
      const values = { a: 5, b: 2, c: 10, d: 0 }
      const source$ = cold('--a---b---c---d---|', values)
      const expectedMarble = '--a---b---c---#'
      animate('       ----r----r---r--')
      const expectedValues = { a: 2, b: 5, c: 1 }
      const result$ = tenDivideByValue(source$)
      expectObservable(result$).toBe(expectedMarble, expectedValues, errorValue)
    })
  })

Testing with an catchError

Again a long struggle to get this working but here we go.

const tenDivideByValueCatchError = (
  source$: Observable<number>,
): Observable<number> | Observable<unknown> => {
  return source$.pipe(
    map((n) => {
      const result = 10 / n
      console.log(`Value we got was ${result}`)
      if (!isFinite(result)) {
        throw new Error('Divide by zero')
      } else {
        return result
      }
    }),
    catchError((error) => {
      return of({})
    }),
  )
}

And here is the test case. The tricky part this time was understanding how to define on frame 4 emit a and complete.

  it('should fail on divide by zero with catch', () => {
    scheduler.run(({ cold, expectObservable }) => {
      const values = { a: 5, b: 2, c: 10, d: 0 }
      const source$ = cold('--a---b---c---d|', values)
      const expectedMarble = '--a---b---c---(d|)'
      const expectedValues = { a: 2, b: 5, c: 1, d: {} }
      const result$ = tenDivideByValueCatchError(source$)
      expectObservable(result$).toBe(expectedMarble, expectedValues)
    })
  })

Testing with an concat with cold

Struggled again to work off the examples on the net but getting through it has allowed me to understand the concepts.

const concatHot = (
  source1$: Observable<number>,
  source2$: Observable<number>,
): Observable<number> => {
  return source1$.pipe(concat(source2$))
}

And the test case

  it('should work with cold observables', () => {
    scheduler.run(({ cold, expectObservable }) => {
      const values1 = { a: 5, b: 2 }
      const source1$ = cold('-a---b-|', values1)

      const values2 = { c: 10, d: 0 }
      const source2$ = cold('-c---d-|', values2)

      const expectedMarbles = '-a---b--c---d-|'
      const expectedValues = { a: 5, b: 2, c: 10, d: 0 }
      const result$ = concatCold(source1$, source2$)
      expectObservable(result$).toBe(expectedMarbles, expectedValues)
    })
  })

Testing with an concat with hot

I don't quite understand how we set the test up in real life. My guess it that the hot observable is mocking the timings of the release of the results.

const concatHot = (
  source1$: Observable<number>,
  source2$: Observable<number>,
): Observable<number> => {
  return source1$.pipe(concat(source2$))
}

Here is the test case which does make sense.

/*
    When testing hot observables you can specify the subscription
    point using a caret ^, similar to how you specify subscriptions
    when utilizing the expectSubscriptions assertion.
*/
  it('should work with hot observables', () => {
    scheduler.run(({ hot, expectObservable }) => {
      const values1 = { a: 5, b: 2 }
      const source1$ = hot('---a--^-b-|', values1)

      const values2 = { c: 10, d: 0 }
      const source2$ = hot('-----c^----d-|', values2)

      const expectedMarbles = '--b--d-|'
      const expectedValues = { b: 2, d: 0 }
      const result$ = concatHot(source1$, source2$)
      expectObservable(result$).toBe(expectedMarbles, expectedValues)
    })
  })

Marble Testing Anotation

You will see the source$ and expectedMarble have characters in them. This is a language that the library supports. Below is a list of some of these which can be found in detail at [1]

  • '-' or '------': Equivalent to NEVER, or an observable that never emits or errors or completes.
  • |: Equivalent to EMPTY, or an observable that never emits and completes immediately.
  • #: Equivalent to throwError, or an observable that never emits and errors immediately.
  • '--a--': An observable that waits 2 "frames", emits value a on frame 2 and then never completes.
  • '--a--b--|': On frame 2 emit a, on frame 5 emit b, and on frame 8, complete.
  • '--a--b--#': On frame 2 emit a, on frame 5 emit b, and on frame 8, error.
  • '-a-^-b--|': In a hot observable, on frame -2 emit a, then on frame 2 emit b, and on frame 5, complete.
  • '--(abc)-|': on frame 2 emit a, b, and c, then on frame 8, complete.
  • '-----(a|)': on frame 5 emit a and complete.
  • 'a 9ms b 9s c|': on frame 0 emit a, on frame 10 emit b, on frame 9,011 emit c, then on frame 9,012 complete.
  • '--a 2.5m b': on frame 2 emit a, on frame 150,003 emit b and never complete.