Pull to refresh

Comments 21

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

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


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

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


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

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

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


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


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

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

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

Ну а пока вы мучаетесь, давайте я расскажу вам как происходит тестирование в $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() ,
    })

}

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

UFO just landed and posted this here
UFO just landed and posted this here

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


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

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

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

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


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

Sign up to leave a comment.

Articles