Back to Blog Home
← all posts

ns test init - @nativescript/unit-test-runner v3 release details - test your apps, your plugins, as well as platform APIs 💪 + integration extras like SonarCloud

December 14, 2021 — by Technical Steering Committee (TSC)

With @nativescript/unit-test-runner v3, we have increased it's versatility and ease of use. We've also added a simple cli flag to enable code coverage reports (--env.codeCoverage). Before looking at how to set things up and run it, let's look at a straightforward test:

import { isIOS } from '@nativescript/core';

class AnyClass {
  hello = `hello ${isIOS ? 'from ios' : 'from android'}`;

  buttonTap() {
    const message = 'hello from platform native apis';
    if (isIOS) {
      console.log(NSString.stringWithString(message + ' on ios').toString());
    } else {
      console.log(new java.lang.String(message + ' on android').toString());
    }
  }
}

describe('Test AnyClass including platform native APIs', () => {
  let anything: AnyClass;

  beforeEach(() => {
    anything = new AnyClass();
    spyOn(console, 'log');
  });

  it("sanity check", () => {
    expect(anything).toBeTruthy();
    expect(anything.hello).toBe(`hello ${isIOS ? 'from ios' : 'from android'}`);
  });

  it('buttonTap should invoke platform native apis', () => {
    anything.buttonTap();
    expect(console.log).toHaveBeenCalledWith(
      `hello from platform native apis on ${isIOS ? 'ios' : 'android'}`
    );
  });
});

You can test any platform native API on iOS or Android fluidly as well as confirm your app's logic is reasonable and sound regarding your expectations.

unit-test-results

Setup

As of NativeScript CLI 8.1.5 (latest cli can be installed with npm i -g nativescript anytime), the v3 unit-test-runner will be setup automatically by running:

ns test init

You can then run unit tests for any target platform:

ns test ios
// or:
ns test android

They run in watch mode by default and are very efficient to run and continually make live updates with.

Generate code coverage reports

ns test ios --env.codeCoverage

ns test android --env.codeCoverage

You can then open the coverage/index.html file to view the coverage report.

unit-test-results

Note on Coverage

By default the report only references files which are touched by tests, with the coverage percentages reflecting that set of code.

If you prefer to include all your code in the report, including those files which are not covered at all by tests you can use the plugin karma-sabarivka-reporter.

  • Install the plugin

    npm install --save-dev karma-sabarivka-reporter 
    
    
  • Update karma.conf.js

    Add sabarivka to the array of reporters

    reporters: [
    // ...
    'sabarivka'
    // ...
    ],
    

    Add an include property to the coverageReporter configuration

    coverageReporter: {
      // ...
        include: [
          // Specify include pattern(s) first
          'src/**/*.(ts|js)',
          // Then specify "do not touch" patterns 
          // (note `!` sign on the beginning of each statement)
          '!src/**/*.spec.(ts|js)',
      //...
      ]
    },
    

The next time you run your tests with coverage enabled the coverage percentages should reflect the entire code base, and the report should include files for which there are no tests.

Test plugins

If you manage your own plugin, suite of plugins, or apps in a workspace via Nx, yarn workspaces or any other project setup where you have internally managed plugins alongside your app codebase you can now easily scoop up those tests as well (even including them in your coverage reports).

Modify the test.ts entry to include source from outside your main app:

import { runTestApp } from '@nativescript/unit-test-runner';
declare let require: any;

runTestApp({
  runTests: () => {
    // tests inside your app
    const tests = require.context("./", true, /\.spec\.ts$/);
    tests.keys().map(tests);

    // tests outside of your app, like internally managed plugins in a workspace style setup
    const pluginTests = require.context('../plugins/my-internal-plugin', true, /\.spec\.ts$/);
    pluginTests.keys().map(pluginTests);
  },
});

You can explore an example repo demonstrating this here.

Flavor example: Angular

You can gain the benefits of the revamped test runner in all flavors but let's take Angular for example and highlight it's usage in practice using the same example.

Additionally we'll add a nifty dumpView utility which will print the view structure as a string which we can use to test if the view rendering worked properly given the bindings. You could create any number of utilities useful to you and your team's testing approaches. For example, instead of creating a string representation of the view binding you could also create an object to traverse view nodes to test.

import { Component } from '@angular/core';
import { ComponentFixture } from '@angular/core/testing';
import { isIOS } from '@nativescript/core';
import { dumpView } from '../unit-test-utils';

@Component({
  template: '<StackLayout><Label [text]="hello"></Label></StackLayout>',
})
class AnyComponent {
  hello = `hello ${isIOS ? 'from ios' : 'from android'}`;

  buttonTap() {
    const message = 'hello from native apis';
    if (isIOS) {
      console.log(NSString.stringWithString(message + ' on ios').toString());
    } else {
      console.log(new java.lang.String(message + ' on android').toString());
    }
  }
}

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

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [AnyComponent],
    }).compileComponents();
    fixture = TestBed.createComponent(AnyComponent);
    fixture.detectChanges();
    component = fixture.componentInstance;
    spyOn(console, 'log');
  });

  it('sanity check', () => {
    expect(component).toBeTruthy();
    expect(component.hello).toBe(
      `hello ${isIOS ? 'from ios' : 'from android'}`
    );
  });

  it('view binding handles iOS/Android specific behavior', () => {
    expect(dumpView(fixture.nativeElement, true)).toBe(
      `(proxyviewcontainer (stacklayout (label[text=hello ${
        isIOS ? 'from ios' : 'from android'
      }])))`
    );
  });

  it('buttonTap should invoke native apis', () => {
    component.buttonTap();
    expect(console.log).toHaveBeenCalledWith([
      `hello from native apis on ${isIOS ? 'ios' : 'android'}`,
    ]);
  });
});
  • unit-test-utils.ts
export function dumpView(view: View, verbose: boolean = false): string {
  let nodeName: string = (<any>view).nodeName;
  if (!nodeName) {
    // Strip off the source
    nodeName = view.toString().replace(/(@[^;]*;)/g, '');
  }
  nodeName = nodeName.toLocaleLowerCase();

  let output = ['(', nodeName];
  if (verbose) {
    if (view instanceof TextBase) {
      output.push('[text=', view.text, ']');
    }
  }

  let children = getChildren(view)
    .map((c) => dumpView(c, verbose))
    .join(', ');
  if (children) {
    output.push(' ', children);
  }

  output.push(')');
  return output.join('');
}

function getChildren(view: View): Array<View> {
  let children: Array<View> = [];
  (<any>view).eachChildView((child: View) => {
    children.push(child);
    return true;
  });
  return children;
}

To unit test with Angular you'll also want to make sure your main test entry also configures the Angular testing environment:

  • test.ts
import { runTestApp } from '@nativescript/unit-test-runner';
declare let require: any;

runTestApp({
  runTests: () => {
    const tests = require.context('./', true, /\.spec\.ts$/);
    // ensure main.spec is included first
    // to configure Angular's test environment
    tests('./main.spec.ts'); 
    tests.keys().map(tests);
  },
});
  • main.spec.ts
import './polyfills';
import 'zone.js/dist/zone-testing.js';
import { TestBed } from '@angular/core/testing';
import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
import { NativeScriptTestingModule } from '@nativescript/angular/testing';

TestBed.initTestEnvironment(
  NativeScriptTestingModule,
  platformBrowserDynamicTesting(),
  { teardown: { destroyAfterEach: true } }
);

Integration extras - SonarSource - SonarCloud

There are many nice integrations you could wire up with the v3 runner.

For example, we'll integrate with SonarCloud which is a cloud-based code quality and security service.

They offer a free account for public open source projects so you could open a free account to give it all a try.

Modify coverageReporter

Modify coverageReporter to include the reporter type that SonarCloud expects:

  • karma.conf.js
coverageReporter: {
    dir: require('path').join(__dirname, './coverage'),
    subdir: '.',
    reporters: [
        { type: 'lcovonly' },
        { type: 'text-summary' }
    ]
},

Include unit test reports

npm i karma-sonarqube-unit-reporter --save-dev

Now modify reporters to include sonarqubeUnit and add the configuration for it:

  • karma.conf.js
// add the reporter
reporters: ['progress', 'sonarqubeUnit'], 

// add the configuration
sonarQubeUnitReporter: {
    sonarQubeVersion: 'LATEST',
    outputDir: require('path').join(__dirname, './SonarResults'),
    outputFile: 'ut_report.xml',
    useBrowserName: false,
    overrideTestDescription: true,
},

Publishing to Sonar

Now when you execute the tests, two reports will be generated

  1. Coverage: ./coverage/lcov.info
  2. Unit test reports: ./SonarResults/ut_report.xml

Sonarcloud provides a script matching your platform (e.g. ./sonarscan.sh) which performs analysis and publishing of reports, they offer guidance and download links when you are setting up your project.

For NativeScript you must pass some properties to sonar to let it know your configuration:

  1. sonar.typescript.tsconfigPath

    Sonar requires a simple tsconfig file to find all your ts files. create a seperate file ( tsconfig.sonar.json ) at the root of the project

     {
       "extends": "./tsconfig.json",
       "include": [
         "./src/**/*.ts",
         "**/*.d.ts"
       ]
     }
    
  2. sonar.tests

    The directory where your tests are contained.

  3. sonar.test.inclusions

    The file pattern which matches your test files.

  4. sonar.testExecutionReportPath

    The file patterns for test execution reports.

  5. sonar.javascript.lcov.reportPaths

    The file patternsfor the coverage reports.

Example properties to be passed:

      -Dsonar.typescript.tsconfigPath=./tsconfig.sonar.json
      -Dsonar.tests=./src/tests  
      -Dsonar.test.inclusions=**/*.spec.ts   
      -Dsonar.sources=./src
      -Dsonar.testExecutionReportPaths=SonarResults\ut_report.xml
      -Dsonar.javascript.lcov.reportPaths=coverage\**\*.info

Run the scanner and your profile now includes unit tests & coverage reports.

unit-test-sonarcloud

Will tests guarantee 0 bugs?

Everyone wishes they could claim that but testing can only help reduce bugs as well as increase the teams confidence around how everyone expects the code to operate under the conditions the team has tested for. They can also help prevent regressions over time as the code evolves by providing coverage to areas you expect to work a certain way and can tell you very quick if something you expected to succeed suddenly fails potentially due to changes that come along in the future.

Doesn't TypeScript alone help prevent bugs?

It absolutely does help - increased strong type checking throughout your codebase can help strengthen the integrity of the code which can increase it's longevity and ease in adaptability to future changes. However TypeScript alone does not take the place of proper unit testing since TypeScript is about code integrity whereas unit testing is about determining the logical success/failure around the operability and behavior of how the code runs. The use of Eslint in conjunction with TypeScript can further guide a best practice implementation.