Angular Testing: Difference between revisions

From bibbleWiki
Jump to navigation Jump to search
Line 61: Line 61:
     });
     });
})
})
 
</syntaxhighlight>
=Setting up Karma=
=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.
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.

Revision as of 03:37, 15 December 2021

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)
    });
})

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.