Содержание:


  • Разработка через тестирование – что это?
  • Три закона TDD
  • Примеры применения
  • Преимущества и недостатки
  • Литература и ссылки


Разработка через тестирование – что это?


Разработка через тестирование (Test-driven development) — техника разработки программного обеспечения, которая определяет разработку через написание тестов. В сущности вам нужно выполнять три простых повторяющихся шага:
— Написать тест для новой функциональности, которую необходимо добавить;
— Написать код, который пройдет тест;
— Провести рефакторинг нового и старого кода.

Мартин Фаулер





Три закона TDD


Сейчас многие знают, что TDD требует от нас писать сначала тесты, прежде чем писать production код. Но это правило — лишь верхушка айсберга. Предлагаю вам рассмотреть следующие три закона:
  1. Не пишется production код, прежде чем для него есть неработающий тест;
  2. Не пишется больше кода юнит теста, чем достаточно для его ошибки. И не компилируемость — это ошибка;
  3. Не пишется больше production кода, чем достаточно для прохождения текущего неработающего теста.

Эти три закона удерживают программиста в цикле, который длится около минуты. Тесты и production код пишутся одновременно, с тестами всего на несколько секунд впереди production кода.
Если мы работаем этим путем, мы напишем десятки тестов в день, сотни каждый месяц, тысячи каждый год. Если мы работаем этим путем, то наши тесты будут полностью покрывать наш production код.

Примеры применения


Пример 1. Сортировка модельных объектов

Рассмотрим простейший пример написания метода сортировки модельных объектов (событий). Сначала пишем интерфейс нового метода (на вход принимает хаотичный массив объектов, а возвращает соответственно отсортированный):

Интерфейс тестируемого метода:

    - (NSArray *)sortEvents:(NSArray *)events

Далее пишем тест:
Используем нотацию given, when, then для деления теста на три логических блока:
  • создание окружения, предусловий теста;
  • совершение действия, вызов тестируемого метода;
  • проверка работы тестируемой функциональности.

Тест:

    - (void)testSortEvents {
       // given
       id firstEvent = [self mockEventWithClass:kCinemaEventType name:@"В"];
       id secondEvent = [self mockEventWithClass:kCinemaEventType name:@"Г"];
       id thirdEvent = [self mockEventWithClass:kPerfomanceEventType name:@"А"];
       id fourthEvent = [self mockEventWithClass:kPerfomanceEventType name:@"Б"];
    
       NSArray *correctSortedArray = @[firstEvent, secondEvent, thirdEvent, fourthEvent];
       NSArray *incorrectSortedArray = @[thirdEvent, secondEvent, fourthEvent, firstEvent];
    
       // when
       NSArray *sortedWithTestingMethodArray = [self.service sortEvents:incorrectSortedArray];
    
       // then
       XCTAssertEqualObjects(correctSortedArray, sortedWithTestingMethodArray, @"Сортировка эвентов работает неправильно");
    }

Только после того, как мы убедились, что тест не выполнится, пишем реализацию метода:

Реализация метода:

    - (NSArray *)sortEvents:(NSArray *)events {
       // Сортировка событий (Сначала кино, затем остальные, обе секции по алфавиту)
       NSMutableArray *cinemaEvents = [[NSMutableArray alloc] init];
       NSMutableArray *otherEvents = [[NSMutableArray alloc] init];
    
       for (Event *event in events) {
          if ([event.type isEqualToString:kCinemaEventType]) {
             [cinemaEvents addObject:event];
          } else {
             [otherEvents addObject:event];
          }
       }
    
       NSComparisonResult (^sortBlock)(Event *firstEvent, Event *secondEvent) = ^NSComparisonResult(Event *firstEvent, Event *secondEvent) {
          return [firstEvent.type compare:secondEvent.type
                                  options:NSNumericSearch];
       };
    
       [cinemaEvents sortUsingComparator:sortBlock];
       [otherEvents sortUsingComparator:sortBlock];
    
       return [cinemaEvents arrayByAddingObjectsFromArray:otherEvents];
    }

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

Очень важная особенность TDD – это инверсия ответственности. При классическом подходе к разработке ПО программист после написания кода самолично проводит тестирование, гоняет код в дебаггере, проверяет работу всех if-ов. В TDD именно тесты подтверждают работоспособность кода.



Пример 2. Маппер

Рассмотрим второй пример – маппер серверной выдачи в модельные объекты.

Маппер должен отвечать следующим требованиям:
1) мапить объекты (неожиданно)
2) обрабатывать кривую выдачу (Null, неверный тип данных, неверная структура выдачи)
3) обеспечивать консистентность данных (проверка обязательных полей)

Пишем интерфейс тестируемого метода, он будет синхронным:

Интерфейс тестируемого метода:

    - (NSArray *)mapEvents:(id)responseObject

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

Тест:

    - (void)testMapEventsResponseSuccessful {
       // given
       NSDictionary *responseObject = @{@"event" : @[@{@"type" : @1,
                                        @"name" : @"test",
                                        @"description" : @"test"}]};
    
       // when
       NSArray *events = [self.mapper mapEvents:responseObject];
    
       // then
       Event *event = events[0];
       [self checkObject:event
         forKeyExistence:@[@"type", @"name", @"description"]];
    }

Для проверки объекта на наличие необходимых полей используем метод-хелпер на KVC:

    - (void)checkObject:(id)object
        forKeyExistence:(NSArray *)keys {
       for (NSString *key in keys) {
          if (![object valueForKey:key]) {
             XCTFail(@"Объект не содержит необходимого поля - %@", key);
          }
       }
    }

Далее пишем простейшую реализацию метода, которая отвечает нашему тесту и проверяем, что тест будет успешно пройден. В данном случае у маппера под капотом используется маппинг движок от RESTKit, но мы рассматриваем наш метод как черную коробку, его реализация в будущем может поменяться. Но требования в большинстве случаем останутся именно такими.

Реализация метода:

    - (NSArray *)mapEvents:(id)responseObject {
       NSDictionary *mappingsDictionary = @{kEventResponseKey : [self eventResponseMapping]};
    
       RKMappingResult *mappingResult = [self mapResponseObject:responseObject
                                         withMappingsDictionary:mappingsDictionary];
    
       if (mappingResult) {
          return [mappingResult array];
       }
    
       return @[];
    }

Отлично, после написания простейшей реализации добавляем еще один тест для маппера, который проверяет, что маппер учитывает обязательность некоторых полей в выдаче, в нашем случае это будет поле id, которое отсутствует в тесте. Задача теста – убедиться, что не произойдет краша, и метод вернет пустой массив после маппинга.

Тест:

    - (void)testMapEventsResponseWithMissingMandatoryFields {
       // given
       NSDictionary *responseObject = @{kEventResponseKey : @[@{@"name" : @"test"}]};
    
       // when
       NSArray *events = [self.mapper mapEvents:responseObject];
    
       // then
       XCTAssertFalse([events count]);
    }

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

Реализация метода:

    - (NSArray *)mapEvents:(id)responseObject {
       NSDictionary *mappingsDictionary = @{kEventResponseKey : [self eventResponseMapping]};
    
       RKMappingResult *mappingResult = [self mapResponseObject:responseObject
                                         withMappingsDictionary:mappingsDictionary];
    
       if (mappingResult) {
          //Проверяем наличие обязательных полей в объекте
          NSArray *events = [self checkMandatoryFields:[mappingResult array]];
          return events;
       }
    
       return @[];
    }

Исходя из первоначальных требований, наш маппер должен корректно себя вести в случае наличия Null-ов в выдаче, пишем проверяющий это тест:

    - (void)testMapEventsResponseWithNulls {
       // given
       NSDictionary *responseObject = @{kEventResponseKey : @[@{@"type" : @1,
                                                                @"name" : [NSNull null],
                                                                @"description" : [NSNull null]}]};
    
       // when
       NSArray *events = [self.mapper mapEvents:responseObject];
    
       // then
       Event *event = events[0];
       XCTAssertNotNil(event.type, @"");
       XCTAssertNil(event.name, @"");
    }

После написания и запуска теста видим, что он сразу успешно выполняется. Да, такое тоже бывает, существующий код может уже отвечать новым требованиям. В такой ситуации мы, конечно же, не удаляем этот тест, т.к. его задача и в будущем, при возможном изменении реализации маппера, проверять корректность его работы. Далее добавляем тест для неправильного типа данных, вместо числа – строка. Маппинг движок от RestKit умеет и с этим справляться, так что тест опять же будет гореть зеленым цветом.

    - (void)testMapEventsResponseWithWrongType {
       // given
       NSDictionary *responseObject = @{kEventResponseKey : @[@{@"type" : @"123"}]};
    
       // when
       NSArray *events = [self.mapper mapEvents:responseObject];
    
    
       // then
       Event *event = events[0];
       XCTAssertTrue([event.type isEqual:@123]);
    }


Пример 3. Сервис получения событий

Рассмотрим сервис получения модельных объектов (событий), протокол будет содержать следующие методы:

    @interface EventService : NSObject
    
       - (instancetype)initWithClient:(id<Client>)client
                               mapper:(id<Mapper>)mapper;
            
       - (NSArray *)obtainEventsForType:(EventType)eventType;
            
       - (void)updateEventsForType:(EventType)eventType
                           success:(SuccessBlock)success
                           failure:(ErrorBlock)failure;
     @end

Сервис будет работать с CoreData, поэтому для тестирования нам нужно будет проинициализоровать стэк CoreData в памяти. Используем для работы с CoreData библиотеку MagicalRecord, не забываем вызывать [MagicalRecord cleanUp] в teardown. В наш сервис инжектируются сетевой клиент и маппер, поэтому передаем в инициализатор их моки.

    @interface EventServiceTests : XCTestCase
    
       @property (nonatomic, strong) EventService *eventService;
       @property (nonatomic, strong) id<Client> clientMock;
       @property (nonatomic, strong) id<Mapper> mapperMock;
            
    @end
            
    @implementation EventServiceTests
            
    - (void)setUp {
       [super setUp];
            
       [MagicalRecord setDefaultModelFromClass:[self class]];
       [MagicalRecord setupCoreDataStackWithInMemoryStore];
            
       self.clientMock = OCMProtocolMock(@protocol(Client));
       self.mapperMock = OCMProtocolMock(@protocol(Mapper));
            
       self.eventService = [[EventService alloc] initWithClient:self.clientMock
                                                         mapper:self.mapperMock];
    }
            
    - (void)tearDown {
       self.eventService = nil;
       self.clientMock = nil;
       self.mapperMock = nil;
       [MagicalRecord cleanUp];
            
       [super tearDown];
    }

Добавляем тест для метода obtain — синхронное получение объектов из базы данных:

    - (void)testObtainEventsForType {
       // given
       Event *event = [Event MR_createEntity];
       event.eventType = EventTypeCinema;
    
       // when
       NSArray *events = [self.eventService obtainEventsForType:EventTypeCinema];
    
       // then
       XCTAssertEqualObjects(event, [events firstObject]);
    }

Реализация метода obtain и инициализатора сервиса:

    - (instancetype)initWithClient:(id<Client>)client
                            mapper:(id<Mapper>)mapper {
       self = [super init];
       if (self) {
          _client = client;
          _mapper = mapper;
       }
       return self;
    }
            
    - (NSArray *)obtainEventsForType:(EventType)eventType {
       NSPredicate *predicate = [NSPredicate predicateWithFormat:@"eventType = %d", eventType];
       NSArray *events = [Event MR_findAllWithPredicate:predicate];
            
       return events;
    }

Добавляем тест для метода update – запрашивает у сервера события, используя клиент, затем маппит в модель и сохраняет в базу. Стабаем клиент, чтобы он всегда возвращал success блок. Проверяем, что наш сервис обратится к мапперу и вызовет success блок:

    - (void)testUpdateEventsForTypeSuccessful {
       // given
       XCTestExpectation *expectation = [self expectationWithDescription:@"Callback"];
       OCMStub([self.clientMock requestEventsForType:EventTypeCinema
                                             success:OCMOCK_ANY
                                             failure:OCMOCK_ANY).andDo(^(NSInvocation *invocation) {
          SuccessBlock block;
          [invocation getArgument:&block atIndex:3];
          block();
       });
    
       // when
       [self.eventService updateEventsForType:EventTypeCinema
                                      success:^{
                                           [expectation fulfill];
                                    } failure:^(NSError *error) {
       }];
    
       // then
       [self waitForExpectationsWithTimeout:DefaultTestExpectationTimeout handler:nil];
       OCMVerify([self.mapperMock mapEvents:OCMOCK_ANY
                                    forType:EventTypeCinema
                             mappingContext:OCMOCK_ANY]);
    }

Пишем тест на сценарий ошибки метода update, стабаем клиент так, чтобы он возвращал ошибку:

    - (void)testUpdateEventsForTypeFailure {
       // given
       XCTestExpectation *expectation = [self expectationWithDescription:@"Callback"];
       NSError *clientError = [NSError errorWithDomain:@""
                                                  code:1
                                              userInfo:nil];
    
       OCMStub([self.clientMock requestEventsForType:EventTypeCinema
                                             success:OCMOCK_ANY
                                             failure:OCMOCK_ANY).andDo(^(NSInvocation *invocation) {
          ErrorBlock block;
          [invocation getArgument:&block atIndex:4];
          block(error);
       });
    
       // when
       [self.eventService updateEventsForType:EventTypeCinema
                                      success:^{
    
                                    } failure:^(NSError *error) {
          XCTAssertEqual(clientError, error)
          [expectation fulfill];
       }];
    
       // then
       [self waitForExpectationsWithTimeout:DefaultTestExpectationTimeout handler:nil];
    }

Реализация метода update:

    - (void)updateEventsForType:(EventType)eventType
                        success:(SuccessBlock)success
                        failure:(ErrorBlock)failure {
       @weakify(self);
       [self.client requestEventsForType:eventType
                                 success:^(id responseObject) {
          @strongify(self);
    
          NSManagedObjectContext *context = [NSManagedObjectContext MR_rootSavingContext];
          NSArray *events = [self.mapper mapEvents:responseObject
                                           forType:eventType
                                    mappingContext:context];
          [context MR_saveToPersistentStoreAndWait];
    
          success();
    
       } failure:^(NSError *error) {
          failure(error);
       }];
    }

Преимущества и недостатки


Преимущества

  • Продумывание интерфейса до реализации
    Одно из основных преимуществ, TDD вынуждает программиста в первую очередь детально продумать работу новой функциональности, а потом уже написать ее.

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

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

  • Меньше ошибок
    Достигается за счет совокупности других преимуществ TDD, в первую очередь, за счет продумывания реализации заранее и покрытия тестами.

  • Документация тестами
    Тесты являются частью документации проекта, существующей кодовой базы. Часто при приходе нового разработчика на проект проще разобраться с кодом на каком-то конкретном примере, а тест – это и есть простейший пример использования кода. В итоге при полном покрытие кода тестами – мы получаем по сути обширную практическую документацию кода.

  • Модульность
    Необходимость тестировать абсолютно все ветви выполнения кода, все if-ы вынуждает нас писать модульно, ибо мы просто не сможем протестировать всемогущий метод, который делает сразу 10 дел в нескольких потоках. Мы стремимся к тому, что каждый класс, каждый метод делает свою небольшую часть общей работы. (Следуя принципу single responsibility).

  • Полноценные тесты
    Полагаю, что многие писали тесты, но не все писали их по TDD и разница на самом деле ощутима. В случае написания тестов после реализации мы очень вероятно можем не учесть все сценарии выполнения метода, и в итоге получить неполноценные тесты. Мы считаем, что наш метод работает, потому что тест работает, но на самом деле в методе не покрыты тестами все if-ы, а при TDD такое невозможно, потому что if не может появиться в коде без теста.


Недостатки

  • Сложность применения (безопасность данных, UI, базы данных)
    Конечно, часто мы сталкиваемся с тем, что некоторые вещи сложно или невозможно протестировать. Иногда бывает, что сложно сходу представить как какой-то модуль будет работать.

  • Больше времени
    Когда начинаешь писать по TDD – время разработки может увеличиться до нескольких раз. Но, буквально через несколько месяцев будет уже сложно писать без него. В перспективе при наличии опыта написания тестов и применения TDD, время на разработку будет уменьшаться. Особенно это будет ощущаться на дистанции, на долгоживущих проектах.

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

  • Поддержка тестов
    Кодовая база при полном покрытии кода тестами увеличивает практически в два раза. И важно понимать, что весь этот код нужно поддерживать, рефакторить и документировать.




Разработка через тестирование поощряет простой дизайн и внушает уверенность.
TDD encourages simple designs and inspires confidence.

Кент Бек

Литература и ссылки


Kent Beck – Test-Driven Development: By example
Robert C. Martin – Clean code
www.objc.io/issue-15
en.wikipedia.org/wiki/Test-driven_development
qualitycoding.org/objective-c-tdd
agiledata.org/essays/tdd.html#WhatIsTDD
www.basilv.com/psd/blog/2009/test-driven-development-benefits-limitations-and-techniques
martinfowler.com/articles/is-tdd-dead
habrahabr.ru/post/206828
habrahabr.ru/post/216923
iosunittesting.com

Комментарии (9)


  1. ianbrode
    06.08.2015 18:00

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


    1. geakstr
      06.08.2015 18:23

      Не ручаюсь, что в sqlite применяют TDD, но это излюбленный всеми пример проекта, полностью покрытого тестами. У них даже статья про это есть https://www.sqlite.org/testing.html


  1. Nadoedalo
    07.08.2015 17:43

    я конечно извиняюсь, но почему именно TDD на iOS? Описанные практики почти никак не касаются собственно iOS и могли бы быть спокойно написаны на псевдокоде.
    Я бы ещё заменил слово «if» на слово «ветвление», потому что некоторые функциональные языки спокойно обходятся без них.


  1. JasF
    07.08.2015 19:33
    +2

    Идея TDD на iOS вызывает отрицательные эмоции.
    Понимаете, TDD обрекает программиста на гипер велосипедность. Зачем вообще писать код? Все компоненты уже готовы и лежат на github, от малых библиотек до великих проектов, которые нужно брать и менять под собственные бизнес задачи. А наличие third-parties кода ставит под сомнение объективность тестирования, никто не знает где выстрелит скачанный код.
    А отсутствие third-parties кода ставит под сомнение адекватность поставленной задачи :-)


    1. bronenos
      08.08.2015 08:09
      +1

      Полагаю, одно из самых важных при TDD — это обеспечение уверенности в том, что код работает так, как он должен работать. Выполнять ту задачу, которую на него возлагают.

      И если вдруг очередной прогон теста покажет, например, что валидатор Email работает некорректно или не поддерживает ряд правил, то это повод заменить 3party валидатор или сделать форк.

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


      1. hastewave
        08.08.2015 11:38

        Тестировать чужие библиотеки — не желательно, они сами должны быть по-хорошему покрыты тестами. Обычный сценарий — мокируем все 3party и тестируем наш код в изоляции.

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


  1. InstaRobot
    08.08.2015 09:39
    -1

    TDD — всегда служила поводом для споров программистов! Так стоит или не стоит? Однозначного ответа нет, это зависит от подхода разработчика к своей работе. Кому то не нужно, кому то очень сильно облегчает жизнь. Мне нравится сама концепция ТДД, которая заставляет писать надежный и самодокументированный код. К этому приходишь, когда начинает надоедать возиться с отладчиком и хочешь сразу привести в минимуму отладку.


  1. agee
    08.08.2015 10:12

    Здравствуйте. Спасибо за хорошую статью.
    Учитывая приведенные Недостатки (а именно «Больше времени» и «Поддержка тестов»), как Вы считаете, хорошим ли компромиссом будет использовать TDD подход только по отношению «критически важным» классам?
    Критичность в таком случае, естественно, оценивается самими программистом/командой уже в зависимости от конкретного проекта.


    1. hastewave
      08.08.2015 11:52
      +1

      Да, вполне разумное решение. Я бы выделил не определенные классы, а, скажем, сервисный слой приложения (бизнес логику), всю работу с сетью, маппинг и валидацию данных.