Angular 5: Unit тесты

С помощью unit тестов мы можем удостовериться, что отдельные части приложения работают именно так, как мы от них ожидаем.

Это в некоторой степени спасает от поломок существующий код, помогает прояснить — как он будет работать в тех или иных случаях. И, в конце концов, позволяет посмотреть на код, так скажем, со стороны, чтобы увидеть его слабые стороны.

Даже существует мнение, что сложно тестируемый код — претендент на переписывание.

Цель данной статьи — помочь в написании unit тестов для Angular 5+ приложения. Пусть это будет увлекательный процесс, а не головная боль.

Изолированные или Angular Test Bed?


Что касается unit тестирования Angular приложения, то можно выделить два вида тестов:

  • Изолированные — те, которые не зависят от Angular. Они проще в написании, их легче читать и поддерживать, так как они исключают все зависимости. Такой подход хорош для сервисов и пайпов.
  • Angular Test Bed — тесты, в которых с помощью тестовой утилиты TestBed осуществляется настройка и инициализация среды для тестирования. Утилита содержит методы, которые облегчают процесс тестирования. Например, мы можем проверить, создался ли компонент, как он взаимодействует с шаблоном, с другими компонентами и с зависимостями.

Изолированные


При изолированном подходе мы тестируем сервис как самый обыкновенный класс с методами.

Сначала создаем экземпляр класса, а затем проверяем, как он работает в различных ситуациях.

Прежде чем переходить к примерам, необходимо обозначить, что я пишу и запускаю тесты с помощью jest, так как понравилась его скорость. Если вы предпочитаете karma + jasmine, то примеры для вас также будут актуальны, поскольку различий в синтаксисе совсем немного.

Jasmine/jest различия
jasmine.createSpy('name') --> jest.fn()
and.returnValue() --> mockReturnValue()
spyOn(...).and.callFake(() => {}) --> jest.spyOn(...).mockImplementation(() => {})

Рассмотрим пример сервиса для модального окна. У него всего лишь два метода, которые должны рассылать определенное значение для переменной popupDialog. И совсем нет зависимостей.

import { Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs/ReplaySubject';

@Injectable()
export class PopupService {

  private popupDialog = new ReplaySubject<{popupEvent: string, component?, options?: {}}>();

  public popupDialog$ = this.popupDialog.asObservable();

  open(component, options?: {}) {
    this.popupDialog.next({popupEvent: 'open', component: component, options: options});
  }

  close() {
    this.popupDialog.next({popupEvent: 'close'});
  }

}

При написании тестов не нужно забывать о порядке выполнения кода. Например, действия, которые необходимо выполнить перед каждым тестом, мы помещаем в beforeEach.
Так, созданный в коде ниже экземпляр сервиса нам понадобится для каждой проверки.

import { PopupService } from './popup.service';
import { SignInComponent } from '../components/signin/signin.component';

describe('PopupService', () => {
  let service: PopupService;
  // создаем экземпляр PopupService
  beforeEach(() => { service = new PopupService(); });
  // done нужно, чтобы тест не завершился до получения данных
  it('subscribe for opening works', (done: DoneFn) => {
    // вызываем метод open
    service.open(SignInComponent, [{title: 'Попап заголовок', message: 'Успешно'}]);
    // при изменении значения popupDialog$ должен сработать subscribe
    service.popupDialog$.subscribe((data) => {
      expect(data.popupEvent).toBe('open');
      done();
    });

  });
  it('subscribe for closing works', (done: DoneFn) => {
    service.close();
    service.popupDialog$.subscribe((data) => {
      expect(data.popupEvent).toBe('close');
      done();
    });
  });
});

Angular Test Bed тесты


Простой компонент


А теперь посмотрим на всю мощь утилиты TestBed. В качестве примера для начала возьмем простейший компонент:

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

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';
}

Файл шаблона:

<h1>
  Welcome to {{ title }}!
</h1>

Файл тестов разберем по кусочкам. Для начала задаем TestBed конфигурацию:

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

compileComponents — метод, делающий вынесенные в отдельные файлы стили и шаблон встроенными.

Этот процесс является асинхронным, так как компилятор Angular должен получить данные из файловой системы.

Иногда compileComponents не нужен
Если вы используете WebPack, то этот вызов и метод async вам не нужен.
Дело в том, что WebPack автоматически перед запуском тестов встраивает внешние стили и шаблон.

Соответственно, и при прописывании стилей и шаблона внутри файла компонента компилировать самостоятельно не надо.

Для тестов необходимо, чтобы компоненты скомпилировались до того, как через метод createComponent() будут созданы их экземпляры.

Поэтому тело первого BeforeEach мы поместили в asynс метод, благодаря чему его содержимое выполняется в специальной асинхронной среде. И пока не будет выполнен метод compileComponents(), следующий BeforeEach не запустится:

beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp = fixture.componentInstance;
  });

Благодаря вынесению в beforeEach всех общих данных, дальнейший код получается значительно чище.

Для начала проверим создание экземпляра компонента и его свойство:

it('should create the comp',  => {
  expect(comp).toBeTruthy();
});
it(`should have as title 'app'`, () => {
   expect(comp.title).toEqual('app');
});

Далее мы хотим проверить, что переменная компонента title вставляется в DOM. При этом мы ожидаем, что ей присвоено значение 'app'. А это присваивание происходит при инициализации компонента.

Запустив с помощью detectChanges CD цикл, мы инициализируем компонент.
До этого вызова связь DOM и данных компонента не произойдет, а следовательно тесты не пройдут.

  it('should render title in a h1 tag', () => {
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h1').textContent)
      .toContain('Welcome to app!');
  });

Полный код теста компонента
import { TestBed, async, ComponentFixture } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {

  let comp: AppComponent;
  let fixture: ComponentFixture<AppComponent>;
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    comp = fixture.componentInstance;
  });

  it('should create the comp', () => {
    expect(comp).toBeTruthy();
  });
  it(`should have as title 'app'`, () => {
    expect(comp.title).toEqual('app');
  });
  it('should render title in a h1 tag', () => {
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h1').textContent)
      .toContain('Welcome to app!');
  });
});


Компонент с зависимостями


Давайте усложним наш компонент, внедрив в него сервис:

export class AppComponent {
  constructor(private popup: PopupService) { }
  title = 'app';
}

Вроде бы пока не особо усложнили, но тесты уже не пройдут. Даже если вы не забыли добавить сервис в providers AppModule.

Потому что в TestBed эти изменения тоже нужно отразить:

TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
      providers: [PopupService]
    });

Мы можем указать сам сервис, но обычно лучше заменить его на класс или объект, который описывает именно то, что нам необходимо для тестов.

Почему?

А вы представьте сервис с кучей зависимостей и вам все придется при тестировании прописать. Не говоря уже о том, что мы тестируем в данном случае именно компонент. Вообще, тестировать что-то одно — это как раз про unit тесты.

Итак, прописываем стаб следующим образом:

const popupServiceStub = {
    open: () => {}
};

Методы задаем только те, которые тестируем.

Если хотим описать стаб как класс
class popupServiceStub {
    open() {}
}

providers: [{provide: PopupService, useClass: popupServiceStub } ]


В TestBed конфигурацию добавляем providers:

providers: [{provide: PopupService, useValue: popupServiceStub } ]

Не стоит путать PopupService и PopupServiceStab. Это разные объекты: первый — клон второго.

Отлично, но мы же сервис внедряли не просто так, а для использования:

ngOnInit() {
    this.popup.open(SignInComponent);
}

Теперь стоит убедиться, что метод действительно вызывается. Для этого сначала получим экземпляр сервиса.

Так как в данном случае сервис задан в providers корневого модуля, то мы можем сделать так:

popup = TestBed.get(PopupService);

А как еще?
Если бы речь шла о сервисе, который прописан в providers компонента, то пришлось бы получать его так:

popup = fixture.debugElement.injector.get(PopupService);


Наконец сама проверка:

it('should called open', () => {
    const openSpy = jest.spyOn(popup, 'open');
    fixture.detectChanges();
    expect(openSpy).toHaveBeenCalled();
  });

Наши действия:

  1. Устанавливаем шпиона на метод open объекта popup.
  2. Запускаем CD цикл, в ходе которого выполнится ngOnInit с проверяемым методом
  3. Убеждаемся, что он был вызван.

Заметьте, что проверяем мы именно вызов метода сервиса, а не то, что он возвращает или другие вещи, касающиеся самого сервиса. Их , чтобы сохранить рассудок, стоит тестить в сервисе.

Сервис с http


Совсем недавно (в Angular 4) файл тестов сервиса с запросами мог выглядеть воистину устрашающе.

Вспомнить, как это было
beforeEach(() => TestBed.configureTestingModule({
    imports: [HttpModule],
    providers: [
      MockBackend,
      BaseRequestOptions,
      {
        provide: Http,
        useFactory: (backend, defaultOptions) => new Http(backend, defaultOptions),
        deps: [MockBackend, BaseRequestOptions]
      },
      UserService
    ]
  }));


Впрочем, и сейчас в интернете полно статей с этими примерами.

А меж тем разработчики Angular не сидели сложа руки, и мы теперь можем писать тесты намного проще. Просто воспользовавшись HttpClientTestingModule и HttpTestingController.

Рассмотрим сервис:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { ReplaySubject } from 'rxjs/ReplaySubject';

import { Game } from '../models/gameModel';
import { StatisticsService } from './statistics.service';

@Injectable()
export class GameService {
  gameData: Array<Game>;
  dataChange:  ReplaySubject<any>;
  gamesUrl = 'https://any.com/games';

  constructor(private http: HttpClient, private statisticsService: StatisticsService) {
    this.dataChange  = new ReplaySubject();
  }

  getGames() {
    this.makeResponse()
      .subscribe((games: Array<Game>) => {
        this.handleGameData(games);
      });
  }

  makeResponse(): Observable<any> {
    return this.http.get(this.gamesUrl);
  }
  handleGameData(games) {
    this.gameData = games;
    this.doNext(games);
    this.statisticsService.send();
  }

  doNext(value) {
    this.dataChange.next(value);
  }

}

Для начала описываем всех наших глобальных героев:

let http: HttpTestingController;
let service: GameService;
let statisticsService: StatisticsService;
const statisticsServiceStub = {
    send: () => {}
};

Тут из интересного — стаб statisticsService. Мы по аналогии с компонентом стабим зависимости, так как тестим сейчас только конкретный сервис.

Как видите, я просто прописала именно то, что понадобится в этом тесте. Просто представьте, что в StatisticsService на самом деле огромное количество методов и зависимостей, а используем в данном сервисе мы только один метод.

Далее объявим данные, которые будем подкидывать в ответ на запрос:

  const expectedData = [
    {id: '1', name: 'FirstGame', locale: 'ru', type: '2'},
    {id: '2', name: 'SecondGame', locale: 'ru', type: '3'},
    {id: '3', name: 'LastGame',  locale: 'en', type: '1'},
  ];

В TestBed необходимо импортировать HttpClientTestingModule и прописать все сервисы:

TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
      ],
      providers: [GameService, { provide: StatisticsService, useValue: statisticsServiceStub }]
    });

Следующий шаг — получение экземпляров всех сервисов, которые нам понадобятся:

    service = TestBed.get(GameService);
    statisticsService = TestBed.get(StatisticsService);
    http = TestBed.get(HttpTestingController);

Не помешает сразу же прописать в afterEach проверку на то, что нет отложенных запросов:

afterEach(() => {
    http.verify();
});

И переходим к самим тестам. Самое простое, что мы можем проверить — создался ли сервис. Если вы забудете в TestBed указать какую-либо зависимость, то этот тест не пройдет:

 it('should be created', () => {
    expect(service).toBeTruthy();
  });

Дальше уже интереснее — проверяем, что по ожидаемому запросу получим определенные данные, которые сами же и подкидываем:

it('should have made one request to GET data from expected URL', () => {

    service.makeResponse().subscribe((data) => {
      expect(data).toEqual(expectedData);
    });

    const req = http.expectOne(service.gamesUrl);
    expect(req.request.method).toEqual('GET');
    req.flush(expectedData);
  });

Не помешает проверить еще и как работает ReplaySubject, то есть будут ли отлавливаться у подписчиков полученные игры:

it('getGames should emits gameData', () => {

    service.getGames();

    service.dataChange.subscribe((data) => {
      expect(data).toEqual(expectedData);
    });
    
    const req = http.expectOne(service.gamesUrl);
    req.flush(expectedData);

  });

И наконец последний пример — проверка, что statisticsService метод send будет вызван:

 it('statistics should be sent', () => {
    const statisticsSpy = jest.spyOn(statisticsService, 'send');
    service.handleGameData(expectedData);
     expect(statisticsSpy).toHaveBeenCalled();
  });

Полный код тестов
import { TestBed } from '@angular/core/testing';

import { GameService } from './game.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { StatisticsService } from './statistics.service';

import 'rxjs/add/observable/of';

describe('GameService', () => {
  let http: HttpTestingController;
  let service: GameService;
  let statisticsService: StatisticsService;
  const statisticsServiceStub = {
    send: () => {}
  };

  const expectedData = [
    {id: '1', name: 'FirstGame', locale: 'ru', type: '2'},
    {id: '2', name: 'SecondGame', locale: 'ru', type: '3'},
    {id: '3', name: 'LastGame',  locale: 'en', type: '1'},
  ];


  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule,
      ],
      providers: [GameService, { provide: StatisticsService, useValue: statisticsServiceStub }]
    });

    service = TestBed.get(GameService);
    statisticsService = TestBed.get(StatisticsService);
    http = TestBed.get(HttpTestingController);

  });

  afterEach(() => {
    http.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should have made one request to GET data from expected URL', () => {

    service.makeResponse().subscribe((data) => {
      expect(data).toEqual(expectedData);
    });

    const req = http.expectOne(service.gamesUrl);
    expect(req.request.method).toEqual('GET');
    req.flush(expectedData);
  });


  it('getGames should emits gameData', () => {

    service.getGames();

    service.dataChange.subscribe((data) => {
      expect(data).toEqual(expectedData);
    });
    
    const req = http.expectOne(service.gamesUrl);
    req.flush(expectedData);

  });

  it('statistics should be sent', () => {
    const statisticsSpy = jest.spyOn(statisticsService, 'send');
    service.handleGameData(expectedData);
    expect(statisticsSpy).toHaveBeenCalled();
  });


});


Как облегчить тестирование?


  1. Выбирайте тот тип тестов, который подходит в данной ситуации и не забывайте про суть unit тестов
  2. Убедитесь, что знаете все возможности вашей IDE в плане помощи при тестировании
  3. При генерации сущностей с помощью Angular-cli автоматически генерируется и файл тестов
  4. Если в компоненте множество таких зависимостей, как директивы и дочерние компоненты, то можно отключить проверку их определения. Для этого в TestBed конфигурации прописываем NO_ERRORS_SCHEMA:

    TestBed.configureTestingModule({
        declarations: [ AppComponent ],
        schemas:      [ NO_ERRORS_SCHEMA ]
      })

Без послесловия не обойтись


Охватить в одной статье все моменты, чтобы она при этом не стала устрашающей (а-ля документация), довольно сложно. Но мне кажется, главное — понять, какие у вас есть инструменты и что с ними нужно делать, а дальше уже бесстрашно сталкиваться на практике как с банальными, так и с нетривиальными случаями.

Если после прочтения статьи вам стало что-то немного понятнее — ура!
У вас есть что добавить? Вы с чем-то не согласны?

Что ж, может быть, ради ваших ценных комментариев это статья и затевалась.

P.S. Ах да, вот ссылка на все примеры.

Также для всех интересующихся Angular может быть полезно русское Angular сообщество в телеграмме.
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 21
  • 0
    Стабы замучаетесь писать для каждых многометодных сервисов. В помощь ts-mockito!
    • 0
      Интересно, гляну. А вообще, как минимум, некоторые IDE уже могут автоматом стабы генерировать. Правда, я не пробовала.
    • 0

      В статье есть фрагмент кода


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

      Здесь обявлена асинхронная функция, но нет await. Получается, что окончания compileComponents ждать не будет, а сразу передаст управление дальше.


      Это опечатка или так задумано?

      • +1
        async в данном случае ангуляр утилита, а не jasmine фича. Код обернутый в async запускается в специальной асинхронной среде, в которой запрятан весь механизм. В общем, по сути упрощение написание кода, которое может видимо сбить с толку.
        • +4

          Понятно, спасибо.


          Очень хороший пример, почему лучше не называть свои функции зарезирвированными словами:


          async(() => {}) // вызов функции 'async'
          async () => {} // объявление асинхронной функции

          Внешне разница в пару скобочек, но логическая разница — огромная.

      • 0
        Статья прям под стать! Спасибо. Как раз осваиваю тестирование в Angular :)
        • –1

          Ну а пока вы мучаетесь, давайте я расскажу вам как происходит тестирование в $mol, чтобы поглумиться над вашей участью :-)


          Вам не нужно заучивать все 100500 методов jasmine-api. Вы просто используете один из 3 видов ассертов:


          $mol_assert_equal — все аргументы идентичны
          $mol_assert_unique — все аргументы разные
          $mol_assert_fail — исполнение валится с ошибкой


          Например, возьмём кота:


          Заголовок спойлера
          class Cat {
          
              lifecycle() {
                  this.eat()
                  this.crap()
              }
          
              eat() {}
              crap() {}
          }

          Нам нужно проверить, что при вызове lifecycle кот выполняет два действия ровно по одному разу. При этом важно не перепутать последовательность этих действий! Как это будет на Жасмине, Жести и тп штуках? Ну что-то типа:


          Заголовок спойлера
          describe( 'Сat' , () => {
          
              it( 'should eat', () => {
          
                  const cat = new Cat('Lion')
          
                  spyOn( cat , 'eat' ).and.callFake( ()=> undefined )
          
                  cat.lifecycle()
          
                  expect( cat.eat ).toHaveBeenCalledTimes( 1 )
              } )
          
              it( 'should crap', () => {
          
                  const cat = new Cat('Lion')
          
                  spyOn( cat , 'crap' ).and.callFake( ()=> undefined )
          
                  cat.lifecycle()
          
                  expect( cat.crap ).toHaveBeenCalledTimes( 1 )
              } )
          
              it( 'should eat then crap', () => {
          
                  const cat = new Cat('Lion')
          
                  spyOn( cat , 'eat' ).and.callFake( ()=> undefined )
                  spyOn( cat , 'crap' ).and.callFake( ()=> undefined )
          
                  cat.lifecycle()
          
                  // so how to assert calling order?
              } )
          
          } )

          В $mol же это будет один простой и понятный тест, проверяющий ровно то, что требуется:


          Заголовок спойлера
          $mol_test({
              'Cat should eat then crap' () {
          
                  const cat = new Cat
          
                  let log = ''
                  cat.eat = ()=> { log += 'eat;' }
                  cat.crap = ()=> { log += 'crap;' }
          
                  cat.lifecycle()
          
                  $mol_assert_equal( log , 'eat;crap;' )
              }
          })

          Всё, что вам нужно знать — это JavaScript и несколько простых функций для ассертов. При этом в стектрейсе в случае падения будет писаться имя упавшего теста, а не всякая жасминовская белиберда.


          Чтобы создать компонент не нужны никакие TestBed, detectChanges и прочие configureTestingModule — вы просто создаёте экземпляр и поехали тестировать:


          const app = new $mol_app_hello
          app.name( 'Jin' )
          
          $mol_assert_equal( app.greeting() , 'Hello, Jin!' )

          Тут мы проверили апи компонента, но можем опуститься и глубже по структуре компонент:


          const app = new $mol_app_hello
          app.Name().value( 'Jin' )
          
          $mol_assert_equal( app.Greeting().sub()[0] , 'Hello, Jin!' )

          А можем и ещё глубже, до DOM элементов:


          const app = new $mol_app_hello
          app.Name().dom_tree().value = 'Jin'
          
          $mol_assert_equal( app.Greeting().dom_tree().textContent , 'Hello, Jin!' )

          А как же зависимости? Да точно так же:


          Заголовок спойлера
          const app = new $mol_app_todomvc
          
          // Клонируем контекст
          app.$ = Object.create( app.$ )
          
          // Мочим локальное хранилище
          const storage = app.$.$mol_state_local = class< Value > extends $mol_state_local_mock< Value > {}
          
          // Заполняем хранилище
          storage.value( 'mol-todos' , '[1,2]' )
          
          // Проверяем, что данные из хранилища восстанавливаются верно
          $mol_assert_equal( app.task_ids().toString() , '1,2' )

          Всё, потратив всего 5 минут времени вы уже знаете как читать и писать любые $mol тесты. При этом получая в результате:


          • Быстрое написание тестов (меньше бойлерплейта, не нужно лазить по докам в поисках нужного метода)
          • Простое написание тестов (обычный JS/TS, единообразное строго типизированное апи)
          • Понятные стектрейсы (имя теста показывается как имя функции, стек не завален лишними вызовами)
          • Быстрые тесты (для сравнения, у меня 12 тривиальных тестов ангуляровских компонент идут 2 секунды, а 86 тестов $mol компонент пробегают за 100мс)
          • Исчерпывающие тесты (проверяем то, что надо, а не то, что позволяет тестовый фреймворк; за использование toHaveBeenCalled вместо toHaveBeenCalledTimes я бы руки отрывал, ибо пропускает целый класс довольно гадких ошибок)
          • 0

            Ну уж нет. Банальный манки-патчинг под видом хороших тестовых практик вы нам не продадите.


            При всех недостатках и сложности Angular — использование DI делает код супер-тестируемым.

            • 0
              Такой ли это банальный манки-патчинг в этом случае? Тут патчинг используется в тех же целях, что и конструктор.

              1. Типобезопасность остается, т.к. ts проверяет интерфейс заменяемых методов
              2. Патч находится всегда рядом с местом создания экземпляра класса и применяется на новый экзепляр, одновременно несколько патчей тут наложить невероятно, читабельность остается приемлемой, т.к. патч рядом с созданием.
              3. Патчинг app.$ похож на один из паттернов DI — ambiant context, который как раз в примерах vintage используется. Его реализация намного проще любого di, основанном на инжекции в конструктор, а возможности не хуже.
              4. Класс проектируется сразу с дефолтными реализациями зависимостей, которые потом можно легко переопределить.

              Инжекция через конструктор в DI хоть и делает код тестируемым, но ценой копипаста. В нормальных языках есть что-то вроде scala case classes или kotlin data classes, которые упрощают настройку объекта.
              В ts такое поведение нормально не сымитировать, поэтому патчинг используется для настройки экземпляра класса. Это не иммутабельно и если этим правилом пренебречь, можно нарушить безопасность. Тут вопрос приоритетов, можно ли этим пренебречь, ведь взамен упрощается и унифицируется настройка экземпляров классов.

              Сахар в ts не способствует улучшению читабельности. При создании объекта нет названий аргументов:
              class A {
                constructor(public v?: string = '') {}
              }
              var a = new A('test')
              

              Что б создание объекта читалось чуть лучше, можно условиться, что аргумент — объект, то это уже ведет к еще большему копипасту аргументов и их типов:
              class A {
                public v: string
                constructor(opts: {v?: string}) { this.v = opts.v || '' }
              }
              var a = new A({v: 'test'})
              

              А вот патчинг:
              class A {
                public v: string = ''
              }
              var a = new A()
              a.v = 'test'
              

              • 0
                Такой ли это банальный манки-патчинг в этом случае? Тут патчинг используется в тех же целях, что и конструктор.

                При инжекции зависимостей через конструктор у вас есть четкая граница "мое — чужое", сразу понятно что именно тестируется.


                При манки-патчинге таких границ нет. Как вы определяете какие методы нужно запатчить? Как донести эту информацию до членов команды, чтобы они были в состоянии это поддерживать?

                • 0

                  Есть два противоположных подхода:


                  Жёсткие зависимости. Это когда ваш юнит не заработает, пока вы не предоставите ему все необходимые зависимости. Жёсткие зависимости логично выносить в конструктор, чтобы точно не забыть какую-либо из них. Чем больше у вас декомпозиция, тем больше каждому юниту требуется зависимостей и тем многословней происходит инстанцирование и тем больше кода занимается только лишь пробрасыванием зависимостей с верхних уровней к нижним. Чтобы побороть эту проблему используются IoC контейнеры, типа ангуляровских модулей, которые сами резолвят для вас эти зависимости на основе контекста. Всё, что вы говорите в шаблоне вашего компонента — это "хочу вот здесь такой-то компонент", при этом какие ему потребуются зависимости в общем случае вы не знаете. То есть объявление в конструкторе всех зависимостей компонента, становится бесполезным, так не даёт исчерпывающую информацию обо всех зависимостях всего вложенного в него дерева компонент, без которых он не заработает. Соответственно, когда вы настраиваете IoC, то должны либо в явном виде запровайдить все эти фактически неявные, но необходимые зависимости (жёсткий вариант), либо компонент должен давать подсказки для IoC контейнера, какую реализацию брать по умолчанию (мягкий вариант). И тут мы плавно переходим к другой крайности...


                  Мягкие зависимости. Мы объявляем зависимости, тут же предоставляем реализацию по умолчанию и реализуем простой механизм переопределения этой зависимости. Чтобы воспользоваться юнитом ничего не нужно кроме как воспользоваться им. Не нужно настраивать какой-то внешний реестр зависимостей. И только если какое-либо его поведение вас не устраивает — вы можете подменить соответствующую зависимость. А чтобы эта подмена действовала не только на сам юнит, но и на все ниже по иерархии (иерархия может быть самой разнообразной и в том числе динамической), используется паттерн "окружающий контекст". Простейшая реализация этой логики выглядит так:


                  Заголовок спойлера
                  // global context alias for code consistency
                  const $ = this
                  
                  class Thing {
                  
                      // local context for self and inner
                      protected $ : typeof $
                  
                      constructor( context : typeof $ ) {
                          this.$ = context
                      }
                  
                  }
                  
                  function derivedContext( patch : Partial< typeof $ > ) {
                      return Object.assign( Object.create( this ) , patch )
                  }
                  
                  class Man extends Thing {
                      hands = [
                          new this.$.Hand( this.$ ) ,
                          new this.$.Hand( this.$ ) ,
                      ]
                  }
                  
                  class Hand extends Thing {
                      fingers = [
                          new this.$.Finger( this.$ ) ,
                          new this.$.Finger( this.$ ) ,
                          new this.$.Finger( this.$ ) ,
                          new this.$.Finger( this.$ ) ,
                          new this.$.Finger( this.$ ) ,
                      ]
                  }
                  
                  class Finger extends Thing {}
                  class TriggerFinger extends Thing {}
                  
                  // new Man with default hands and fingers
                  const Jin = new this.$.Man( this.$ )
                  
                  // new Man with trigger fingers
                  const John = new this.$.Man( this.$.derivedContext({
                      Finger : TriggerFinger
                  }) )
                  
                  // new Man that can't have trigger fingers
                  const Jack = new this.$.Man( this.$.derivedContext({
                      TriggerFinger : Finger 
                  }) )

                  В $mol реализация хитрее, с неявным пробросом контекста и возможностью динамического его изменения.

                  • 0

                    Ну вот, совсем другое дело.


                    Только это совсем не похоже на подход, который вы продемонстрировали в изначальном комменте.

                    • 0

                      Основная суть та же. Да, забыл ещё рассказать про киллер-фичу — возможность мочить даже нативные апи. Например, используем локальное хранилище:


                      const Config extends Thing {
                      
                          get teme() {
                              this.$.localStorage['theme'] || 'light'
                          }
                      
                          set teme( next : string ) {
                              this.$.localStorage['theme'] = next
                          }
                      
                      }

                      Подсовываем вместо реального хранилище фейковое:


                      new Settings( this.$.derivedContext({
                          localStorage : {}
                      }) )

                      В Ангуляре для этого пришлось бы заворачивать нативное апи в сервис. И так для каждого апи. В стандартной поставке, конечно же, такой сервис в комплекте не идёт. А единственный уже готовый модуль, который я нашёл, не ставится из-за устаревшего npm хука.

                      • 0
                        Основная суть та же

                        Патчить собственно тестируемый объект или окружающий контекст — большая разница. Собственно, именно предложение патчить тестируемые классы побудило меня написать первый коммент.
                        С идеей ambient context и подменой значений в this.$ все хорошо

                        • 0

                          Патч объектов позволяет легко и просто реализовывать связывание опять же по мягкой схеме. Например, возьмём кота:


                          class Cat {
                          
                              static make<Obj>(
                                  this : { new() : Obj } ,
                                  patch : Partial<Obj> ,
                              ) {
                                  return Object.assign( new this , patch ) as Obj
                              }
                          
                              name() { return 'Anonymous' }
                          }

                          А теперь создадим владельца:


                          class Owner {
                          
                              catName = 'Lion'
                          
                              cat = Cat.make({
                                  name : ()=> this.catName() ,
                              })
                          
                          }

                          Теперь, когда бы вы ни спросили у кота, как его зовут — он будет возвращать имя, хранящееся у владельца.

                          • 0

                            Дмитрий, я ещё больше вас зауважал. А что можно почитать чтобы разобраться в том, что вы написали?

                              • 0

                                Object.assign вроде не поддерживает рекурсию. Его надо будет явно втыкать в каждый вложенный объект?

                                • 0

                                  Конечно, либо втыкать, либо патчить снаружи. Второй вариант лучше поддерживается языковыми сервисами. С первым у меня не работали подсказки. То есть в $mol можно писать и так:


                                  const app = $mol_app_todomvc.make({
                                      $ : { __proto__ : $ ,
                                          $mol_state_local : class< Value > extends $mol_state_local_mock< Value > {} ,
                                      }
                                  })

                                  Оно будет тайпчекаться, но подсказок при вводе не будет :(

              • 0

                Хороший/плохой — весьма скользкие понятия. Вы думаете что spyOn под капотом делает? Неявно заменяет метод, чем вполне может сломать ваш метод. Мой пример тоже супер-тестируемый. Разница лишь в объёме бойлерплейта и в понимании что происходит.

                • 0

                  spyOn делает тоже самое, но речь совсем не об этом.


                  Разговор здесь о нормальном DI против манки-патчинга. На маленьких примерах в пару строк с котиками можно и в патчах разобраться. На больших классах с десятком методов, которые писались разными людьми, лучше иметь более строгий протокол.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое