Статьи

Как использовать макеты в тестах контроллера

Даже с тех пор, как я начал писать тесты для своего приложения Grails, я не мог найти много статей об использовании макетов. Все говорят о тестах и ​​TDD, но если вы ищете их, статей не так много.

Сегодня я хочу поделиться с вами тестом с макетами для простого и полного сценария. У меня есть простое приложение, которое может получить твиты Twitter и представить его пользователю. Я использую сервис REST и использую GET для получения твитов по идентификатору, например: http://api.twitter.com/1/statuses/show/236024636775735296.json . Вы можете скопировать и вставить его в свой браузер, чтобы увидеть результат.

Мое приложение использует Grails 2.1 с spock-0.6 для тестов. У меня есть TwitterReaderService который извлекает твиты по идентификатору, а затем анализирует ответ в своем классе твитов.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class TwitterReaderService {
    Tweet readTweet(String id) throws TwitterError {
        try {
            String jsonBody = callTwitter(id)
            Tweet parsedTweet = parseBody(jsonBody)
            return parsedTweet
        } catch (Throwable t) {
            throw new TwitterError(t)
        }
    }
 
    private String callTwitter(String id) {
        // TODO: implementation
    }
 
    private Tweet parseBody(String jsonBody) {
        // TODO: implementation
    }
}
 
class Tweet {
    String id
    String userId
    String username
    String text
    Date createdAt
}
 
class TwitterError extends RuntimeException {}

TwitterController играет главную роль здесь. Пользователи вызывают действие show вместе с id твита. Это действие — мой тестируемый предмет. Я реализовал некоторые основные функции. На этом легче сосредоточиться при написании тестов.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
class TwitterController {
    def twitterReaderService
 
    def index() {
    }
 
    def show() {
        Tweet tweet = twitterReaderService.readTweet(params.id)
        if (tweet == null) {
            flash.message = 'Tweet not found'
            redirect(action: 'index')
            return
        }
 
        [tweet: tweet]
    }
}

Давайте начнем писать тест с нуля. Самое главное, что я использую макет для моего TwitterReaderService . Я не new TwitterReaderService() , потому что в этом тесте я тестирую только TwitterController . Я не заинтересован в инъекционном обслуживании. Я знаю, как эта служба должна работать, и я не интересуюсь внутренним оборудованием. Поэтому перед каждым тестом я twitterReaderServiceMock в контроллер:

01
02
03
04
05
06
07
08
09
10
11
import grails.test.mixin.TestFor
import spock.lang.Specification
 
@TestFor(TwitterController)
class TwitterControllerSpec extends Specification {
    TwitterReaderService twitterReaderServiceMock = Mock(TwitterReaderService)
 
    def setup() {
        controller.twitterReaderService = twitterReaderServiceMock
    }
}

Теперь пришло время подумать, какие сценарии мне нужно протестировать. Эта строка из TwitterReaderService является наиболее важной:

1
Tweet readTweet(String id) throws TwitterError

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

  • TwitterError
  • null может быть возвращен
  • Tweet экземпляр может быть возвращен

Этот список — ваш тестовый проект. Теперь ответьте на простой вопрос для каждого элемента: «Что я хочу, чтобы мой контроллер делал в этой ситуации?» и у вас есть план теста:

  • show action должен перенаправить на индекс, если TwitterError брошен и сообщить об ошибке
  • show action должен перенаправить на индекс и сообщить, если твит не найден
  • show действие должно показать найденный твит

Это было легко и просто! А теперь лучшая часть: мы используем twitterReaderServiceMock чтобы высмеивать каждый из этих трех сценариев!

В Споке есть хорошая документация по взаимодействию с макетами . Вы объявляете, какие методы вызываются, сколько раз, какие параметры передаются и что должны быть возвращены. Помните черный ящик? Макет — это ваш черный ящик с подробными инструкциями, например: я ожидаю, что если вы получите ровно один вызов readTweet с параметром ‘1’, вы должны выбросить мне TwitterError . Перефразируйте это предложение вслух и посмотрите на это:

1
1 * twitterReaderServiceMock.readTweet('1') >> { throw new TwitterError() }

Это правильное определение взаимодействия на макете! Это так просто! Вот полный тест, который пока не проходит:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import grails.test.mixin.TestFor
import spock.lang.Specification
 
@TestFor(TwitterController)
class TwitterControllerSpec extends Specification {
    TwitterReaderService twitterReaderServiceMock = Mock(TwitterReaderService)
 
    def setup() {
        controller.twitterReaderService = twitterReaderServiceMock
    }
 
    def "show should redirect to index if TwitterError is thrown"() {
        given:
            controller.params.id = '1'
        when:
            controller.show()
        then:
            1 * twitterReaderServiceMock.readTweet('1') >> { throw new TwitterError() }
            0 * _._
            flash.message == 'There was an error on fetching your tweet'
            response.redirectUrl == '/twitter/index'
    }
}
1
2
3
| Failure:  show should redirect to index if TwitterError is thrown(pl.refaktor.twitter.TwitterControllerSpec)
|  pl.refaktor.twitter.TwitterError
 at pl.refaktor.twitter.TwitterControllerSpec.show should redirect to index if TwitterError is thrown_closure1(TwitterControllerSpec.groovy:29)

Вы можете заметить нотацию 0 * _._ . Это говорит: я не хочу никаких других издевательств или любых других методов, вызываемых. Сбой этого теста, если что-то называется! Это хорошая практика, чтобы убедиться, что нет больше взаимодействий, чем вы хотите.

Хорошо, теперь мне нужно реализовать логику контроллера для обработки TwitterError .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
class TwitterController {
 
    def twitterReaderService
 
    def index() {
    }
 
    def show() {
        Tweet tweet
 
        try {
            tweet = twitterReaderService.readTweet(params.id)
        } catch (TwitterError e) {
            log.error(e)
            flash.message = 'There was an error on fetching your tweet'
            redirect(action: 'index')
            return
        }
 
        [tweet: tweet]
    }
}

Мои тесты проходят! У нас осталось два сценария. Правило остается прежним: TwitterReaderService возвращает что-то, и мы проверяем это. Так что эта строка является сердцем каждого теста, меняйте только возвращаемые значения после >> :

1
1 * twitterReaderServiceMock.readTweet('1') >> { throw new TwitterError() }

Вот полный тест для трех сценариев и контроллера, который его проходит.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import grails.test.mixin.TestFor
import spock.lang.Specification
 
@TestFor(TwitterController)
class TwitterControllerSpec extends Specification {
 
    TwitterReaderService twitterReaderServiceMock = Mock(TwitterReaderService)
 
    def setup() {
        controller.twitterReaderService = twitterReaderServiceMock
    }
 
    def "show should redirect to index if TwitterError is thrown"() {
        given:
            controller.params.id = '1'
        when:
            controller.show()
        then:
            1 * twitterReaderServiceMock.readTweet('1') >> { throw new TwitterError() }
            0 * _._
            flash.message == 'There was an error on fetching your tweet'
            response.redirectUrl == '/twitter/index'
    }
 
    def "show should inform about not found tweet"() {
        given:
            controller.params.id = '1'
        when:
            controller.show()
        then:
            1 * twitterReaderServiceMock.readTweet('1') >> null
            0 * _._
            flash.message == 'Tweet not found'
            response.redirectUrl == '/twitter/index'
    }
 
    def "show should show found tweet"() {
        given:
            controller.params.id = '1'
        when:
            controller.show()
        then:
            1 * twitterReaderServiceMock.readTweet('1') >> new Tweet()
            0 * _._
            flash.message == null
            response.status == 200
    }
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class TwitterController {
 
    def twitterReaderService
 
    def index() {
    }
 
    def show() {
        Tweet tweet
 
        try {
            tweet = twitterReaderService.readTweet(params.id)
        } catch (TwitterError e) {
            log.error(e)
            flash.message = 'There was an error on fetching your tweet'
            redirect(action: 'index')
            return
        }
 
        if (tweet == null) {
            flash.message = 'Tweet not found'
            redirect(action: 'index')
            return
        }
 
        [tweet: tweet]
    }
}

Самое главное, что мы протестировали взаимодействие между контроллером и сервисом без реализации логики в сервисе! Вот почему имитирующая техника так полезна. Он разъединяет ваши зависимости и позволяет сосредоточиться только на одном тестируемом предмете. Удачного тестирования!

Ссылка: Как использовать макеты в тестах контроллера от нашего партнера JCG Томаша Калкосинского в блоге рефактора .