Angular2 unit test with @Input()
Asked Answered
F

4

95

I've got a component that uses the @Input() annotation on an instance variable and I'm trying to write my unit test for the openProductPage() method, but I'm a little lost at how I setup my unit test. I could make that instance variable public, but I don't think I should have to resort to that.

How do I setup my Jasmine test so that a mocked product is injected (provided?) and I can test the openProductPage() method?

My component:

import {Component, Input} from "angular2/core";
import {Router} from "angular2/router";

import {Product} from "../models/Product";

@Component({
    selector: "product-thumbnail",
    templateUrl: "app/components/product-thumbnail/product-thumbnail.html"
})

export class ProductThumbnail {
    @Input() private product: Product;


    constructor(private router: Router) {
    }

    public openProductPage() {
        let id: string = this.product.id;
        this.router.navigate([“ProductPage”, {id: id}]);
    }
}
Formalin answered 15/4, 2016 at 18:52 Comment(1)
I wrote a short blog about testing Components with @Input() that explains a few ways to test the input you want: medium.com/@AikoPath/…Jittery
P
18

I usually do something like:

describe('ProductThumbnail', ()=> {
  it('should work',
    injectAsync([ TestComponentBuilder ], (tcb: TestComponentBuilder) => {
      return tcb.createAsync(TestCmpWrapper).then(rootCmp => {
        let cmpInstance: ProductThumbnail =  
               <ProductThumbnail>rootCmp.debugElement.children[ 0 ].componentInstance;

        expect(cmpInstance.openProductPage()).toBe(/* whatever */)
      });
  }));
}

@Component({
 selector  : 'test-cmp',
 template  : '<product-thumbnail [product]="mockProduct"></product-thumbnail>',
 directives: [ ProductThumbnail ]
})
class TestCmpWrapper { 
    mockProduct = new Product(); //mock your input 
}

Note that product and any other fields on the ProductThumbnail class can be private with this approach (which is the main reason I prefer it over Thierry's approach, despite the fact that it's a little more verbose).

Popover answered 15/4, 2016 at 19:37 Comment(2)
Do you still need to inject TestComponentBuilder? see: medium.com/@AikoPath/…Jittery
For developers who seek the "pure testbed" approach there are some answers down there in this post: https://mcmap.net/q/222885/-angular2-unit-test-with-input and https://mcmap.net/q/222885/-angular2-unit-test-with-input This particular answer is not wrong, but it is more of a 'hack' than real unit test approachSeethe
H
78

this is from official documentation https://angular.io/docs/ts/latest/guide/testing.html#!#component-fixture. So you can create new input object expectedHero and pass it to the component comp.hero = expectedHero

Also make sure to call fixture.detectChanges(); last, otherwise property will not be bound to component.

Working Example

// async beforeEach
beforeEach( async(() => {
    TestBed.configureTestingModule({
        declarations: [ DashboardHeroComponent ],
    })
    .compileComponents(); // compile template and css
}));

// synchronous beforeEach
beforeEach(() => {
    fixture = TestBed.createComponent(DashboardHeroComponent);
    comp    = fixture.componentInstance;
    heroEl  = fixture.debugElement.query(By.css('.hero')); // find hero element

    // pretend that it was wired to something that supplied a hero
    expectedHero = new Hero(42, 'Test Name');
    comp.hero = expectedHero;
    fixture.detectChanges(); // trigger initial data binding
});
Hanse answered 3/5, 2017 at 9:18 Comment(4)
where is the hero element usedDunston
Aniruddha Das - it will be used if you bind to any properties of the hero in the html. I had the same problem exactly and this solution is simple to implement, and you get to create a mock object right here in the test. This should be the accepted answer.Guru
Using before each to set data that needs to be dynamic for each test seems like a really bad pattern for writing tests that need to test anything more than one specific caseParabasis
One important thing to consider, if your class implements OnInit: The ngOnInit() method is called (only) after the first call of detectChanges(). Therefore be carefully with calling detectChanges() in beforeEach.Weirick
S
57

If you use TestBed.configureTestingModule to compile your test component, here's another approach. It's basically the same as the accepted answer, but may be more similar to how angular-cli generates the specs. FWIW.

import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement } from '@angular/core';

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

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ 
        TestComponentWrapper,
        ProductThumbnail
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA]
    })
    .compileComponents();

    fixture = TestBed.createComponent(TestComponentWrapper);
    component = fixture.debugElement.children[0].componentInstance;
    fixture.detectChanges();
  });

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

@Component({
  selector: 'test-component-wrapper',
  template: '<product-thumbnail [product]="product"></product-thumbnail>'
})
class TestComponentWrapper {
  product = new Product()
}
Sturgill answered 13/12, 2016 at 3:58 Comment(6)
I am trying what you suggest above.. but when I do, I get a "Uncaught ReferenceError: Zone is not defined" . I am using a virtual clone of the code you have shown above. (with the addition of my own includes as below): import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { testContentNavData } from './mok-definitions'; import { ContentNavComponent } from '../app/content-nav/content-nav.component'; import {} from 'jasmine';Gerhardt
That looks like a Zone.js error, so it's hard to say. Are you using Angular CLI? Perhaps provide a link to the full error getting logged in your console.Sturgill
I followed your approached however my component being tested having the template '<p [outerHTML]="customFieldFormatted"></p>' and it never passes tests. Everything works fine, component gets rendered correctly but html is not added. If i change to <p>{{ customFieldFormatted }}</p> everything works fine. Not sure why [outerHTML] does not work. Do you have any idea? thank youWitching
@KimGentes, I believe, some provider configuration is missing which resulted in 'Uncaught ReferenceError: Zone is not defined' issue. What I do in such scenario is adding try-catch block around TestBed.configureTestingModule() and write the error to console. That shows which provider is missing. Just adding this comment so that in future it may help someone.Boysenberry
I think this answer needs to be improved, it doesn't go all the way to demonstrate how one is to not use a static Product on the wrapper component, thus leading a naive person to write a component wrapper for every test case of distinct product as input.Parabasis
@CaptainPrinny what would you suggest? If I understand what you're saying correctly, you're suggesting that the developer would like to provide different instances of Product with different property values specified for different test cases, is that correct? I can't immediately see how to do that. If you do, feel free to share and I'll update the answer, because I see the value in what you're saying.Sturgill
E
34

You need to set the product value on the component instance after it has been loaded within your test.

As a sample here is a simple component within an input that you can use as a foundation for your use case:

@Component({
  selector: 'dropdown',
  directives: [NgClass],
  template: `
    <div [ngClass]="{open: open}">
    </div>
  `,
})
export class DropdownComponent {
  @Input('open') open: boolean = false;

  ngOnChanges() {
    console.log(this.open);
  }
}

And the corresponding test:

it('should open', injectAsync([TestComponentBuilder], (tcb: TestComponentBuilder) => {
  return tcb.createAsync(DropdownComponent)
  .then(fixture => {
    let el = fixture.nativeElement;
    let comp: DropdownComponent = fixture.componentInstance;

    expect(el.className).toEqual('');

    // Update the input
    comp.open = true; // <-----------

    // Apply
    fixture.detectChanges(); // <-----------

    var div = fixture.nativeElement.querySelector('div');
    // Test elements that depend on the input
    expect(div.className).toEqual('open');
  });
}));

See this plunkr as a sample: https://plnkr.co/edit/YAVD4s?p=preview.

Edgeworth answered 15/4, 2016 at 19:35 Comment(6)
In OP's example, the @Input property being set is private. Unless I'm mistaken, this approach is not going to work in that case, because tsc is going to barf on the reference to a private field.Popover
Thanks for pointing this out! I missed that the field was private. I thought again about your comment and the "private" aspect. I wonder if it's a good thing to have the private keyword on this field since it's not actually "private"... I mean it will be updated from outside the class by Angular2. Would be interested in having your opinion ;-)Edgeworth
you ask an interesting question, but I think the real question you have to ask then is whether it's a good thing to have private in typescript at all since it's not "actually private" - i.e., since it can't be enforced at runtime, only at compile time. I personally like it, but also understand the argument against it. At the end of the day though, Microsoft choose to have it in TS, and Angular chose TS as a principal language, and I don't think we can flatly say it's a bad idea to use a major feature of a primary language.Popover
Thanks very much for your answer! I'm personally convinced that using TypeScript is a good thing. It actually contributes to improve application quality! I don't think that using private is a bad thing even if it's not really private at runtime :-) That said for this particular case, I'm not sure that is a good thing to use private since the field is managed outside the class by Angular2...Edgeworth
I'm trying to use it with the new TestBed.createComponent but when I call fixture.detectChanges() it does not trigger ngOnChanges call. Do you know how can I test it with the "new system"?Cool
the TestComponentBuilder class has been replaced by the TestBedElfreda
P
18

I usually do something like:

describe('ProductThumbnail', ()=> {
  it('should work',
    injectAsync([ TestComponentBuilder ], (tcb: TestComponentBuilder) => {
      return tcb.createAsync(TestCmpWrapper).then(rootCmp => {
        let cmpInstance: ProductThumbnail =  
               <ProductThumbnail>rootCmp.debugElement.children[ 0 ].componentInstance;

        expect(cmpInstance.openProductPage()).toBe(/* whatever */)
      });
  }));
}

@Component({
 selector  : 'test-cmp',
 template  : '<product-thumbnail [product]="mockProduct"></product-thumbnail>',
 directives: [ ProductThumbnail ]
})
class TestCmpWrapper { 
    mockProduct = new Product(); //mock your input 
}

Note that product and any other fields on the ProductThumbnail class can be private with this approach (which is the main reason I prefer it over Thierry's approach, despite the fact that it's a little more verbose).

Popover answered 15/4, 2016 at 19:37 Comment(2)
Do you still need to inject TestComponentBuilder? see: medium.com/@AikoPath/…Jittery
For developers who seek the "pure testbed" approach there are some answers down there in this post: https://mcmap.net/q/222885/-angular2-unit-test-with-input and https://mcmap.net/q/222885/-angular2-unit-test-with-input This particular answer is not wrong, but it is more of a 'hack' than real unit test approachSeethe

© 2022 - 2024 — McMap. All rights reserved.