Comments 21
В статье есть фрагмент кода
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();
}));
Здесь обявлена асинхронная функция, но нет await
. Получается, что окончания compileComponents
ждать не будет, а сразу передаст управление дальше.
Это опечатка или так задумано?
Ну а пока вы мучаетесь, давайте я расскажу вам как происходит тестирование в $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
я бы руки отрывал, ибо пропускает целый класс довольно гадких ошибок)
Ну уж нет. Банальный манки-патчинг под видом хороших тестовых практик вы нам не продадите.
При всех недостатках и сложности Angular — использование DI делает код супер-тестируемым.
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'
Такой ли это банальный манки-патчинг в этом случае? Тут патчинг используется в тех же целях, что и конструктор.
При инжекции зависимостей через конструктор у вас есть четкая граница "мое — чужое", сразу понятно что именно тестируется.
При манки-патчинге таких границ нет. Как вы определяете какие методы нужно запатчить? Как донести эту информацию до членов команды, чтобы они были в состоянии это поддерживать?
Есть два противоположных подхода:
Жёсткие зависимости. Это когда ваш юнит не заработает, пока вы не предоставите ему все необходимые зависимости. Жёсткие зависимости логично выносить в конструктор, чтобы точно не забыть какую-либо из них. Чем больше у вас декомпозиция, тем больше каждому юниту требуется зависимостей и тем многословней происходит инстанцирование и тем больше кода занимается только лишь пробрасыванием зависимостей с верхних уровней к нижним. Чтобы побороть эту проблему используются 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 реализация хитрее, с неявным пробросом контекста и возможностью динамического его изменения.
Ну вот, совсем другое дело.
Только это совсем не похоже на подход, который вы продемонстрировали в изначальном комменте.
Основная суть та же. Да, забыл ещё рассказать про киллер-фичу — возможность мочить даже нативные апи. Например, используем локальное хранилище:
const Config extends Thing {
get teme() {
this.$.localStorage['theme'] || 'light'
}
set teme( next : string ) {
this.$.localStorage['theme'] = next
}
}
Подсовываем вместо реального хранилище фейковое:
new Settings( this.$.derivedContext({
localStorage : {}
}) )
В Ангуляре для этого пришлось бы заворачивать нативное апи в сервис. И так для каждого апи. В стандартной поставке, конечно же, такой сервис в комплекте не идёт. А единственный уже готовый модуль, который я нашёл, не ставится из-за устаревшего npm хука.
Основная суть та же
Патчить собственно тестируемый объект или окружающий контекст — большая разница. Собственно, именно предложение патчить тестируемые классы побудило меня написать первый коммент.
С идеей ambient context и подменой значений в this.$ все хорошо
Патч объектов позволяет легко и просто реализовывать связывание опять же по мягкой схеме. Например, возьмём кота:
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() ,
})
}
Теперь, когда бы вы ни спросили у кота, как его зовут — он будет возвращать имя, хранящееся у владельца.
Тут я рассказывал про этот подход: https://github.com/nin-jin/slides/tree/master/orp#%D0%90%D0%B1%D1%81%D1%82%D1%80%D0%B0%D0%BA%D1%86%D0%B8%D0%B8
Конечно, либо втыкать, либо патчить снаружи. Второй вариант лучше поддерживается языковыми сервисами. С первым у меня не работали подсказки. То есть в $mol можно писать и так:
const app = $mol_app_todomvc.make({
$ : { __proto__ : $ ,
$mol_state_local : class< Value > extends $mol_state_local_mock< Value > {} ,
}
})
Оно будет тайпчекаться, но подсказок при вводе не будет :(
Хороший/плохой — весьма скользкие понятия. Вы думаете что spyOn под капотом делает? Неявно заменяет метод, чем вполне может сломать ваш метод. Мой пример тоже супер-тестируемый. Разница лишь в объёме бойлерплейта и в понимании что происходит.
Angular 5: Unit тесты