Юнит-тесты в Cocoa

    Ниже описаны основы использования OCUnit — фреймворка для создания юнит-тестов, интегрированного в Xcode. Чтобы наглядно попробовать описываемые вещи, код можно скачать сразу. Писал до эпохи Xcode 4, поэтому картинки немного устарели.



    Итак, например, у нас есть некая категория, которая расширяет стандартный класс NSString и добавляет метод по перевертыванию своего содержимого:

    //ExtendedString.h
    
    #import <Cocoa/Cocoa.h>
     
    @interface NSString (Extended)
     
    - (NSString *)invert;
     
    @end
    

    //ExtendedString.m
    
    #import "ExtendedString.h"
     
    @implementation NSString (Extended)
     
    - (NSString *)invert
    {
        NSUInteger length = [self length];
        NSMutableString *invertedString = [NSMutableString stringWithCapacity: length];
     
        while (length > (NSUInteger)0)
        { 
            unichar c = [self characterAtIndex: --length]; 
            [invertedString appendString: [NSString stringWithCharacters: &c length: 1]];
        }
     
        return invertedString;
    }
     
    @end
    

    Добавляем к проекту новый таргет. Пусть он называется «Tests»:





    Добавляем в проект специальный фреймворк для юнит-тестов — SenTestingKit:



    Его нужно добавить только к таргету Tests:



    Создаем группу для исходников с названием Tests и добавляем в нее новые тест-файлы. К стати, в папке с проектом лучше всего тоже создать физическую подпапку Tests и там уже размещать исходники с тестами:





    Назовем его понятным образом — ExtendedString_Test.m — и добавим к таргету Tests:



    Кроме того, к этому таргету Tests нужно будет присовокупить исходники самого тестируемого класса, а не только тестов. В итоге, структура таргетов в проекте будет выглядеть так:



    Приведем код тестов, снабдив его необходимыми комментариями:

    //ExtendedString_Test.h
    
    #import <SenTestingKit/SenTestingKit.h>  //используем тестовый фреймворк
      
    //а вот если бы мы сделали не категорию, а нормальный класс,
    //то на месте этого комментария следовало бы написать что-то типа:
    //@class MyClass;
    //и использовать MyСlass внутри объявления нижеследующего ExtendedString_Test
    //там, где сейчас NSString
      
    @interface ExtendedString_Test : SenTestCase  //все тест-классы обязаны наследовать это
    {
        NSString *_string;
    }
     
    @end
    

    //ExtendedString_Test.m
    
    #import "ExtendedString_Test.h"
    #import "ExtendedString.h"  //не забудем про подопытный класс
     
    @implementation ExtendedString_Test
     
    - (void)setUp  //это запускается первым, можно настроить подопытные классы и посоздавать объекты
    {
        _string = [[NSString alloc] initWithString: @"Hello world!"];
     
        STAssertNotNil(_string, @"Construct error!");  //сразу проверяем, все ли в порядке
    }
     
    - (void)tearDown  //это запускается последним, можно удалить объекты
    {
        [_string release];
    }
     
    - (void)testInvertString  //это обычный тестовый метод
    {
        STAssertEqualObjects(@"Hello world!", _string, @"String is not initialized!");  //проверяем оригинал
        STAssertEqualObjects(@"!dlrow olleH", [_string invert], @"String is not inverted!");  //проверяем перевертыш
    }
     
    @end
    

    Если все ОК, то сборка таргета Tests просто пройдет успешно. Если какой-то из ассертов окажется невалидным, во время сборки будет показана ошибка, как если бы это была ошибка компиляции. Примерно так:



    Теперь, как эта кухня работает?

    Во время сборки таргета с тестами создается специальный бандл с расширением octest. Следующем шагом в сборке этого таргета стоит запуск скрипта следующего содержания:

    ${SYSTEM_DEVELOPER_DIR}/Tools/RunUnitTests
    Эта штука в свою очередь запускает вложенные скрипты, но все, в конечном итоге, сводится к вызову /Developer/Tools/otest с определенными параметрами. Последний загружает в себя наш тестовый бандл, находит там классы-наследники SenTestCase и дергает за метод setUp в начале, tearDown в конце, а между ними он вызывает все методы, название которых начинается со слова «test». Да-да, Objective-C такое позволяет :)

    Поэтому все тесты нужно начинать с приставки «test».

    К стати, octest можно запустить и вручную. Например так:

    export OBJC_DISABLE_GC=YES #выключим Garbage Collector
    arch -i386 /Developer/Tools/otest ~/InvertString/build/Debug/Tests.octest #укажем явно архитектуру (i386), а то может ругаться

    Вывод будет примерно такой:

    objc[22721]: GC: forcing GC OFF because OBJC_DISABLE_GC is set
    objc[22721]: GC: forcing GC OFF because OBJC_DISABLE_GC is set
    Test Suite '/Users/ium/InvertString/build/Debug/Tests.octest(Tests)' started at 2011-07-01 18:46:45 +0300
    Test Suite 'ExtendedString_Test' started at 2011-07-01 18:46:45 +0300
    /Users/ium/InvertString/Tests/ExtendedString_Test.m:21: error: -[ExtendedString_Test testInvertString] : 'Hello world' should be equal to 'Hello world!' String is not initialized!
    2011-07-01 18:46:45.240 otest[22721:80f] !dlrow olleH
    /Users/ium/InvertString/Tests/ExtendedString_Test.m:22: error: -[ExtendedString_Test testInvertString] : '!dlrow olle' should be equal to '!dlrow olleH' String is not inverted!
    Test Case '-[ExtendedString_Test testInvertString]' failed (0.003 seconds).
    Test Suite 'ExtendedString_Test' finished at 2011-07-01 18:46:45 +0300.
    Executed 1 test, with 2 failures (0 unexpected) in 0.003 (0.003) seconds

    Test Suite '/Users/ium/InvertString/build/Debug/Tests.octest(Tests)' finished at 2011-07-01 18:46:45 +0300.
    Executed 1 test, with 2 failures (0 unexpected) in 0.003 (0.010) seconds

    Ну, это для варианта с ошибками, естественно. Оно все вываливается в лог, к стати (Build > Build Results).

    Получив такое на sdterr во время сборки, Xcode немедленного его парсит, находит ключевое слово «error:» c предваряющими его именем файла и номером строки, и красиво подсвечивает нам ошибки, как на предыдущей картинке.

    Набор макросов для проверки утверждений следующий:

    STFail(description, ...)  //это просто способ вывести сообщение об ошибке, очень полезно, где ассертами не обойтись
    STAssertNil(a1, description, ...)
    STAssertNotNil(a1, description, ...)
    STAssertTrue(expression, description, ...)
    STAssertFalse(expression, description, ...)
    STAssertEqualObjects(a1, a2, description, ...)  //эти объекты равны по содержанию, копии друг друга
    STAssertEquals(a1, a2, description, ...)  //это физически один объект
    STAssertEqualsWithAccuracy(left, right, accuracy, description, ...)
    STAssertThrows(expression, description, ...)
    STAssertThrowsSpecific(expression, specificException, description, ...)
    STAssertThrowsSpecificNamed(expr, specificException, aName, description, ...)
    STAssertNoThrow(expression, description, ...)
    STAssertNoThrowSpecific(expression, specificException, description, ...)
    STAssertNoThrowSpecificNamed(expr, specificException, aName, description, ...)
    STAssertTrueNoThrow(expression, description, ...)
    STAssertFalseNoThrow(expression, description, ...)
    

    Названия говорят сами за себя, кроме прокомментированных. Троеточие работает как подстановка параметров в форматную строку description. Там как обычно, знак процента.

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

    Удачи!

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 5

      +3
      Не хочу показаться вечно недовольным занудой, но в интернете полно примеров, как протестировать код сложения чисел, конкатенации или разворота строк, вычисления факториалов и чисел Фибоначи и тому подобных элементарных вещей. Хотелось бы посмотреть на более приближенный к реальности пример юниттеста. Например, тест для кода который будет использоватся для работы с вебсирвисом и DB, с изоляцией от этих внешних сущностей при помощи OCMock. У автора нет в планах такой статьи?
        0
        Это только введение в OCUnit, причем, далеко не полное. На Хабре эта тема еще не поднималась, поэтому решил затронуть. Конечно, по мере возможности, буду публиковать примеры еще, уже без вступлений.
        +1
        Внимание, не наступите на «стандартные» грабли!

        Если вы где-то в коде используете main bundle (вызовы [NSBundle mainBundle] или ситуации, когда при передаче nil в качестве параметра, метод использует main bundle), то скорее всего получите неожиданные ошибки, связанные с тем, что эти методы вернут bundle, стандартный для тестов — в нем нет ваших ресурсов и файлов.

        В результате код типа [[NSBundle mainBundle] pathForResource:name ofType:extension] в тестах вернет nil, даже если путь и тип ресурса верны и все хорошо в самом приложении. Чтобы этого избежать, берите bundle от какого-то конкретного класса ([NSBundle bundleForClass:]). Если вы разрабатываете под iOS, то можете использовать любой класс «собственного производства», т.к. bundle для них всех все равно один.
          0
          как дебажить unit tests, созданные на основе SenTestingKit?
            0
            Последний загружает в себя наш тестовый бандл, находит там классы-наследники SenTestCase и дергает за метод setUp в начале, tearDown в конце, а между ними он вызывает все методы, название которых начинается со слова «test».


            Маленькое уточнение.
            setUp и tearDown вызываются до и после выполнения КАЖДОГО метода название которого начинается со слова «test»
            Вот пруф:

            Conveniently, you can add a pair of methods to a test suite class that are called before and after each test case method is invoked: setUp and tearDown.

            Only users with full accounts can post comments. Log in, please.