Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Как я перестал работать по выходным и полюбил тесты

Как я перестал работать по выходным и полюбил тесты

Сергей Зароченцев, RedMadRobot at #MOSDROID 17 Chlorine

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

MOSDROID

May 30, 2019
Tweet

More Decks by MOSDROID

Other Decks in Programming

Transcript

  1. View Slide

  2. Как я
    перестал
    работать
    по выходным
    Сергей Зароченцев
    и полюбил
    тесты

    View Slide

  3. “У меня нет на это времени!”
    - разработчик, работающий по
    выходным
    Зачем тестировать?

    View Slide

  4. Тесты это…
    01 Отличная документация на код
    02 Тестируемость - лакмусовая бумажка хорошей архитектуры
    03 Сразу думаем о краевых и негативных сценариях
    04 Нет боязни менять чужой код
    Redmadrobot
    Тестирование
    05 Экономия времени

    View Slide

  5. Тесты это…
    Redmadrobot
    Тестирование
    Тест
    ы
    Не работать по выходным

    View Slide

  6. Тестирование
    Что мы хотим от тестов?
    Redmadrobot
    01
    02
    03
    Просто писать, большое покрытие
    Изменения без страха
    Сложная логика, которую тяжело проверить руками

    View Slide

  7. 1
    Просто писать
    Большое покрытие
    Redmadrobot

    View Slide

  8. Самый лучший тест - ручной.
    Проверяется весь функционал
    целиком. Но очень дорого и долго
    Просто писать. Большое покрытие
    Самый лучший тест
    Redmadrobot

    View Slide

  9. Просто писать. Большое покрытие
    Самый лучший тест
    Redmadrobot
    Тяжело
    отказаться от
    ручных тестов

    View Slide

  10. UI тесты тоже очень дорогие. Их
    трудно писать. Их тяжело запускать,
    они падают и работают долго
    Просто писать. Большое покрытие
    UI тесты
    Redmadrobot

    View Slide

  11. Просто писать. Большое покрытие
    UI тесты
    Redmadrobot

    View Slide

  12. Просто писать. Большое покрытие
    На уровень ниже: ViewModel
    Redmadrobot

    View Slide

  13. Просто писать. Большое покрытие
    Опишем State экрана
    data class CardAdditionViewState(
    val cardNumberState: CardNumberState,
    val isAddCardButtonEnabled: Boolean,
    val screenState: ScreenState) {
    data class CardNumberState(val number: String, val error: CardNumberError) {
    enum class CardNumberError {
    NOT_MIR_PAYMENT_SYSTEM_CARD,
    LUHN_CHECK_INCORRECT,
    NONE }
    }
    sealed class ScreenState {
    object Content : ScreenState()
    object Loading : ScreenState()
    data class Error(val error: Throwable) : ScreenState()
    object Success : ScreenState() }
    }

    View Slide

  14. Просто писать. Большое покрытие
    Опишем State экрана
    data class CardAdditionViewState(
    val cardNumberState: CardNumberState,
    val isAddCardButtonEnabled: Boolean,
    val screenState: ScreenState) {
    data class CardNumberState(val number: String, val error: CardNumberError) {
    enum class CardNumberError {
    NOT_MIR_PAYMENT_SYSTEM_CARD,
    LUHN_CHECK_INCORRECT,
    NONE }
    }
    sealed class ScreenState {
    object Content : ScreenState()
    object Loading : ScreenState()
    data class Error(val error: Throwable) : ScreenState()
    object Success : ScreenState() }
    }

    View Slide

  15. Просто писать. Большое покрытие
    Опишем State экрана
    data class CardAdditionViewState(
    val cardNumberState: CardNumberState,
    val isAddCardButtonEnabled: Boolean,
    val screenState: ScreenState) {
    data class CardNumberState(val number: String, val error: CardNumberError) {
    enum class CardNumberError {
    NOT_MIR_PAYMENT_SYSTEM_CARD,
    LUHN_CHECK_INCORRECT,
    NONE }
    }
    sealed class ScreenState {
    object Content : ScreenState()
    object Loading : ScreenState()
    data class Error(val error: Throwable) : ScreenState()
    object Success : ScreenState() }
    }

    View Slide

  16. Просто писать. Большое покрытие
    Опишем State экрана
    data class CardAdditionViewState(
    val cardNumberState: CardNumberState,
    val isAddCardButtonEnabled: Boolean,
    val screenState: ScreenState) {
    data class CardNumberState(val number: String, val error: CardNumberError) {
    enum class CardNumberError {
    NOT_MIR_PAYMENT_SYSTEM_CARD,
    LUHN_CHECK_INCORRECT,
    NONE }
    }
    sealed class ScreenState {
    object Content : ScreenState()
    object Loading : ScreenState()
    data class Error(val error: Throwable) : ScreenState()
    object Success : ScreenState() }
    }

    View Slide

  17. Просто писать. Большое покрытие
    ViewModel и State
    class CardAdditionViewModel : BaseViewModel() {
    val state = MutableLiveData()
    private var viewState: CardAdditionViewState = CardAdditionViewState.default
    set(value) {
    field = value
    state.onNext(field)
    }
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
    viewModel = obtainViewModel(CardAdditionViewModel.Factory(screenOpenContext))
    observe(viewModel.state, this::render)
    }
    private fun render(viewState: CardAdditionViewState) {
    renderCardNumberViewState(viewState.cardNumberState)
    additionButton.isEnabled = viewState.isAddCardButtonEnabled
    renderScreenState(viewState.screenState)
    }

    View Slide

  18. Просто писать. Большое покрытие
    ViewModel и State
    class CardAdditionViewModel : BaseViewModel() {
    val state = MutableLiveData()
    private var viewState: CardAdditionViewState = CardAdditionViewState.default
    set(value) {
    field = value
    state.onNext(field)
    }
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
    viewModel = obtainViewModel(CardAdditionViewModel.Factory(screenOpenContext))
    observe(viewModel.state, this::render)
    }
    private fun render(viewState: CardAdditionViewState) {
    renderCardNumberViewState(viewState.cardNumberState)
    additionButton.isEnabled = viewState.isAddCardButtonEnabled
    renderScreenState(viewState.screenState)
    }

    View Slide

  19. Просто писать. Большое покрытие
    ViewModel и State
    class CardAdditionViewModel : BaseViewModel() {
    val state = MutableLiveData()
    private var viewState: CardAdditionViewState = CardAdditionViewState.default
    set(value) {
    field = value
    state.onNext(field)
    }
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
    viewModel = obtainViewModel(CardAdditionViewModel.Factory(screenOpenContext))
    observe(viewModel.state, this::render)
    }
    private fun render(viewState: CardAdditionViewState) {
    renderCardNumberViewState(viewState.cardNumberState)
    additionButton.isEnabled = viewState.isAddCardButtonEnabled
    renderScreenState(viewState.screenState)
    }

    View Slide

  20. Просто писать. Большое покрытие
    ViewModel и State
    class CardAdditionViewModel : BaseViewModel() {
    val state = MutableLiveData()
    private var viewState: CardAdditionViewState = CardAdditionViewState.default
    set(value) {
    field = value
    state.onNext(field)
    }
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
    viewModel = obtainViewModel(CardAdditionViewModel.Factory(screenOpenContext))
    observe(viewModel.state, this::render)
    }
    private fun render(viewState: CardAdditionViewState) {
    renderCardNumberViewState(viewState.cardNumberState)
    additionButton.isEnabled = viewState.isAddCardButtonEnabled
    renderScreenState(viewState.screenState)
    }

    View Slide

  21. Просто писать. Большое покрытие
    ViewModel и State
    class CardAdditionViewModel : BaseViewModel() {
    val state = MutableLiveData()
    private var viewState: CardAdditionViewState = CardAdditionViewState.default
    set(value) {
    field = value
    state.onNext(field)
    }
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
    viewModel = obtainViewModel(CardAdditionViewModel.Factory(screenOpenContext))
    observe(viewModel.state, this::render)
    }
    private fun render(viewState: CardAdditionViewState) {
    renderCardNumberViewState(viewState.cardNumberState)
    additionButton.isEnabled = viewState.isAddCardButtonEnabled
    renderScreenState(viewState.screenState)
    }

    View Slide

  22. Просто писать. Большое покрытие
    Открываем требования
    Redmadrobot

    View Slide

  23. Просто писать. Большое покрытие
    TDD
    @Test
    fun `when number starts with 2 - then error should not be show`() {
    // Given
    val viewModel = CardAdditionViewModel(
    schedulersProvider = TrampolineSchedulersProvider(),
    context = mock(),
    router = mock(),
    bindCardUseCase = mock(),
    )
    val mirCardNumber = "2345"
    // When
    viewModel.onCardNumberEntered(mirCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NONE)
    }

    View Slide

  24. Просто писать. Большое покрытие
    Название
    @Test
    fun `when number starts with 2 - then error should not be show`() {
    // Given
    val viewModel = CardAdditionViewModel(
    schedulersProvider = TrampolineSchedulersProvider(),
    context = mock(),
    router = mock(),
    bindCardUseCase = mock(),
    )
    val mirCardNumber = "2345"
    // When
    viewModel.onCardNumberEntered(mirCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NONE)
    }

    View Slide

  25. Просто писать. Большое покрытие
    Given When Then
    @Test
    fun `when number starts with 2 - then error should not be show`() {
    // Given
    val viewModel = CardAdditionViewModel(
    schedulersProvider = TrampolineSchedulersProvider(),
    context = mock(),
    router = mock(),
    bindCardUseCase = mock(),
    )
    val mirCardNumber = "2345"
    // When
    viewModel.onCardNumberEntered(mirCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NONE)
    }

    View Slide

  26. Просто писать. Большое покрытие
    Тестирование RxJava
    @Test
    fun `when number starts with 2 - then error should not be show`() {
    // Given
    val viewModel = CardAdditionViewModel(
    schedulersProvider = TrampolineSchedulersProvider(),
    context = mock(),
    router = mock(),
    bindCardUseCase = mock(),
    )
    val mirCardNumber = "2345"
    // When
    viewModel.onCardNumberEntered(mirCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NONE)
    }

    View Slide

  27. Просто писать. Большое покрытие
    Mocks and Stubs
    @Test
    fun `when number starts with 2 - then error should not be show`() {
    // Given
    val viewModel = CardAdditionViewModel(
    schedulersProvider = TrampolineSchedulersProvider(),
    context = mock(),
    router = mock(),
    bindCardUseCase = mock(),
    )
    val mirCardNumber = "2345"
    // When
    viewModel.onCardNumberEntered(mirCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NONE)
    }

    View Slide

  28. Просто писать. Большое покрытие
    AssertJ
    @Test
    fun `when number starts with 2 - then error should not be show`() {
    // Given
    val viewModel = CardAdditionViewModel(
    schedulersProvider = TrampolineSchedulersProvider(),
    context = mock(),
    router = mock(),
    bindCardUseCase = mock(),
    )
    val mirCardNumber = "2345"
    // When
    viewModel.onCardNumberEntered(mirCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NONE)
    }

    View Slide

  29. Просто писать. Большое покрытие
    TDD
    @Test
    fun `when card number not starts with 2, 3, 6 - then show error`() {
    // Given
    val viewModel = CardAdditionViewModel()
    val visaCardNumber = "4567"
    // When
    viewModel.onCardNumberEntered(visaCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NOT_MIR_PAYMENT_SYSTEM_CARD)
    }

    View Slide

  30. Просто писать. Большое покрытие
    TDD
    @Test
    fun `when card number not starts with 2, 3, 6 - then show error`() {
    // Given
    val viewModel = CardAdditionViewModel()
    val visaCardNumber = "4567"
    // When
    viewModel.onCardNumberEntered(visaCardNumber)
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.NOT_MIR_PAYMENT_SYSTEM_CARD)
    }

    View Slide

  31. Просто писать. Большое покрытие
    TDD
    @Test
    fun `when user enter less then 16 digits - then add button should be disabled`() {
    // Given
    val viewModel = CardAdditionViewModel()
    val notFullyEnteredCardNumber = "12345"
    // When
    viewModel.onCardNumberEntered(notFullyEnteredCardNumber)
    // Then
    assertThat(viewModel.state.value!!.isAddCardButtonEnabled)
    .isFalse()
    }

    View Slide

  32. Просто писать. Большое покрытие
    TDD
    @Test
    fun `when user enter less then 16 digits - then add button should be disabled`() {
    // Given
    val viewModel = CardAdditionViewModel()
    val notFullyEnteredCardNumber = "12345"
    // When
    viewModel.onCardNumberEntered(notFullyEnteredCardNumber)
    // Then
    assertThat(viewModel.state.value!!.isAddCardButtonEnabled)
    .isFalse()
    }

    View Slide

  33. Просто писать. Большое покрытие
    TDD
    @Test
    fun `when user click button - and Luhn check incorrect - then show error and disable button`() {
    // Given
    val viewModel = CardAdditionViewModel()
    val incorrectCardNumber = "2111 2222 3333 4444"
    viewModel.onCardNumberEntered(incorrectCardNumber)
    // When
    viewModel.onAddClick()
    // Then
    assertThat(viewModel.state.value!!.cardNumberState.error)
    .isEqualTo(CardNumberError.LUHN_CHECK_INCORRECT)
    assertThat(viewModel.state.value!!.isAddCardButtonEnabled)
    .isFalse()
    }

    View Slide

  34. Просто писать. Большое покрытие
    Мне завели баг! Что делать?

    View Slide

  35. Просто писать. Большое покрытие
    Пишем тест на баг
    @Test
    fun `when error occurs - and user clicks retry button - then clear card number field`() {
    // Given
    val cardBindingError = MessageServerException()
    val viewModel = CardAdditionViewModel(
    bindCardUseCase = mock { on { bindCard(any()) }.thenReturn(Completable.error(cardBindingError)) },
    )
    // When
    val correctCardNumber = "2202 1118 4013 1368"
    viewModel.onCardNumberEntered(correctCardNumber)
    viewModel.onAddClick()
    viewModel.onRetryClick()
    // Then
    val currentState = viewModel.state.value!!
    assertThat(currentState.cardNumberState.number)
    .isBlank()
    assertThat(currentState.isAddCardButtonEnabled)
    .isFalse()
    }

    View Slide

  36. Просто писать. Большое покрытие
    Пишем тест на баг
    @Test
    fun `when error occurs - and user clicks retry button - then clear card number field`() {
    // Given
    val cardBindingError = MessageServerException()
    val viewModel = CardAdditionViewModel(
    bindCardUseCase = mock { on { bindCard(any()) }.thenReturn(Completable.error(cardBindingError)) },
    )
    // When
    val correctCardNumber = "2202 1118 4013 1368"
    viewModel.onCardNumberEntered(correctCardNumber)
    viewModel.onAddClick()
    viewModel.onRetryClick()
    // Then
    val currentState = viewModel.state.value!!
    assertThat(currentState.cardNumberState.number)
    .isBlank()
    assertThat(currentState.isAddCardButtonEnabled)
    .isFalse()
    }
    Тапнуть на
    привязку карты

    View Slide

  37. Просто писать. Большое покрытие
    Пишем тест на баг
    @Test
    fun `when error occurs - and user clicks retry button - then clear card number field`() {
    // Given
    val cardBindingError = MessageServerException()
    val viewModel = CardAdditionViewModel(
    bindCardUseCase = mock { on { bindCard(any()) }.thenReturn(Completable.error(cardBindingError)) },
    )
    // When
    val correctCardNumber = "2202 1118 4013 1368"
    viewModel.onCardNumberEntered(correctCardNumber)
    viewModel.onAddClick()
    viewModel.onRetryClick()
    // Then
    val currentState = viewModel.state.value!!
    assertThat(currentState.cardNumberState.number)
    .isBlank()
    assertThat(currentState.isAddCardButtonEnabled)
    .isFalse()
    }
    Привязать уже
    привязанную
    карту

    View Slide

  38. Просто писать. Большое покрытие
    Пишем тест на баг
    @Test
    fun `when error occurs - and user clicks retry button - then clear card number field`() {
    // Given
    val cardBindingError = MessageServerException()
    val viewModel = CardAdditionViewModel(
    bindCardUseCase = mock { on { bindCard(any()) }.thenReturn(Completable.error(cardBindingError)) },
    )
    // When
    val correctCardNumber = "2202 1118 4013 1368"
    viewModel.onCardNumberEntered(correctCardNumber)
    viewModel.onAddClick()
    viewModel.onRetryClick()
    // Then
    val currentState = viewModel.state.value!!
    assertThat(currentState.cardNumberState.number)
    .isBlank()
    assertThat(currentState.isAddCardButtonEnabled)
    .isFalse()
    }
    Тапнуть
    “попробовать
    снова”

    View Slide

  39. Просто писать. Большое покрытие
    Пишем тест на баг
    @Test
    fun `when error occurs - and user clicks retry button - then clear card number field`() {
    // Given
    val cardBindingError = MessageServerException()
    val viewModel = CardAdditionViewModel(
    bindCardUseCase = mock { on { bindCard(any()) }.thenReturn(Completable.error(cardBindingError)) },
    )
    // When
    val correctCardNumber = "2202 1118 4013 1368"
    viewModel.onCardNumberEntered(correctCardNumber)
    viewModel.onAddClick()
    viewModel.onRetryClick()
    // Then
    val currentState = viewModel.state.value!!
    assertThat(currentState.cardNumberState.number)
    .isBlank()
    assertThat(currentState.isAddCardButtonEnabled)
    .isFalse()
    }
    Поле ввода
    должно
    очиститься

    View Slide

  40. 2
    Сложная логика
    Сложно тестировать руками
    Redmadrobot

    View Slide

  41. Сложно тестировать вручную
    Время
    Redmadrobot

    View Slide

  42. Сложно тестировать вручную
    Время
    Redmadrobot
    Отлично, тут мы
    и протестируем приветствие
    на экране авторизации

    View Slide

  43. Сложно тестировать вручную
    Выносим код
    Redmadrobot
    class Greeting(
    currentTime: DateTime,
    private val userName: String?
    ) {
    // Ночь: 00:00 - 05:59
    private val night: Interval = Interval(nightStart, morningStart)
    // Утро: 06:00 - 11:59
    private val morning: Interval = Interval(morningStart, dayStart)
    // День: 12:00 - 17:59
    private val day: Interval = Interval(dayStart, eveningStart)
    // Вечер: 18:00 - 23:59
    private val evening: Interval = Interval(eveningStart, end)
    @StringRes
    val greetingRes: Int
    init {
    greetingRes = when {
    night.contains(currentTime) -> R.string.fragment_pin_input_greeting_night
    morning.contains(currentTime) -> R.string.fragment_pin_input_greeting_morning
    day.contains(currentTime) -> R.string.fragment_pin_input_greeting_day
    evening.contains(currentTime) -> R.string.fragment_pin_input_greeting_evening
    else -> throw IllegalStateException("Cant determine greeting for $currentTime")
    }
    }
    fun greet(context: Context): String {
    return context.getString(greetingRes) + if (userName != null) ",\n$userName" else ""
    }
    }

    View Slide

  44. Сложно тестировать вручную
    Пишем тест
    Redmadrobot
    @Test
    fun `when night - then show correct greeting`() {
    // Given
    // 04:20
    val night: DateTime = DateTime().withTime(4, 20, 0, 0)
    // When
    val greeting = Greeting(night, username)
    // Then
    assertThat(greeting.greetingRes).isEqualTo(R.string.fragment_pin_input_greeting_night)
    val expectedResult = "Доброй ночи,\n$username"
    assertThat(greeting.greet(contextMock)).isEqualTo(expectedResult)
    }

    View Slide

  45. Сложно тестировать вручную
    Пишем тест
    Redmadrobot
    @Test
    fun `when night - then show correct greeting`() {
    // Given
    // 04:20
    val night: DateTime = DateTime().withTime(4, 20, 0, 0)
    // When
    val greeting = Greeting(night, username)
    // Then
    assertThat(greeting.greetingRes).isEqualTo(R.string.fragment_pin_input_greeting_night)
    val expectedResult = "Доброй ночи,\n$username"
    assertThat(greeting.greet(contextMock)).isEqualTo(expectedResult)
    }

    View Slide

  46. Сложно тестировать вручную
    Ошибки прячутся на границах
    Redmadrobot
    @Test
    fun `when night end - then show correct greeting`() {
    // Given
    // 05:59
    val night: DateTime = DateTime().withTime(5, 59, 0, 0)
    // When
    val greeting = Greeting(night, username)
    // Then
    assertThat(greeting.greetingRes).isEqualTo(R.string.fragment_pin_input_greeting_night)
    val expectedResult = "Доброй ночи,\n$username"
    assertThat(greeting.greet(contextMock)).isEqualTo(expectedResult)
    }

    View Slide

  47. Сложно тестировать вручную
    Ошибки прячутся на границах
    Redmadrobot
    @Test
    fun `when night end - then show correct greeting`() {
    // Given
    // 05:59
    val night: DateTime = DateTime().withTime(5, 59, 0, 0)
    // When
    val greeting = Greeting(night, username)
    // Then
    assertThat(greeting.greetingRes).isEqualTo(R.string.fragment_pin_input_greeting_night)
    val expectedResult = "Доброй ночи,\n$username"
    assertThat(greeting.greet(contextMock)).isEqualTo(expectedResult)
    }

    View Slide

  48. Сложная логика
    Шифрование
    Redmadrobot

    View Slide

  49. Сложная логика
    Шифрование
    Redmadrobot
    Процедура шифрования:
    1. Получаем из определенного URL публичный ключ для асиметричного шифрования в формате [XXXX]

    2. Генерируем [ДАННЫЕ УТЕРЯНЫ] ключ для генерации сообщения (длина - [СЕКРЕТНО] бит)

    3. Шифруем JSON симметричным ключом с помощью алгоритма AES/CBC/PKCS5Padding

    4. Шифруем симметричный ключ с помощью публичного ключа используя алгоритм RSA/ECB/
    OAEPWithSHA-256AndMGF1Padding

    5. В первый блок пакета включаем шифрованный симметричный ключ, второй блок содержит случайно
    сгенерированный initialization vector, в остальных блоках передается [УДАЛЕНО]

    6. Весь пакет кодируется [СОВЕРШЕННО СЕКРЕТНО]

    View Slide

  50. Сложная логика
    Выносим код
    Redmadrobot
    class BindCardUseCase(
    private val cardsRepository: CardsRepository,
    private val cardNumberCipher: CardNumberCipher
    ) {
    fun bindCard(cardNumber: String): Completable {
    return cardsRepository
    .map { key -> encryptCardNumber(cardNumber, key) }
    .flatMapCompletable { encryptedNumber ->
    cardsRepository.bindCard(encryptedNumber)
    }
    }
    }
    interface CardNumberCipher {
    fun encrypt(cardNumber: String, key: PublicKey): String
    }

    View Slide

  51. Сложная логика
    Выносим код
    Redmadrobot
    class BindCardUseCase(
    private val cardsRepository: CardsRepository,
    private val cardNumberCipher: CardNumberCipher
    ) {
    fun bindCard(cardNumber: String): Completable {
    return cardsRepository
    .map { key -> encryptCardNumber(cardNumber, key) }
    .flatMapCompletable { encryptedNumber ->
    cardsRepository.bindCard(encryptedNumber)
    }
    }
    }
    interface CardNumberCipher {
    fun encrypt(cardNumber: String, key: PublicKey): String
    }

    View Slide

  52. Сложная логика
    Убираем android-зависимости
    Redmadrobot
    class CardNumberCipherImpl(private val base64: (ByteArray) -> String) : CardNumberCipher {
    override fun encrypt(cardNumber: String, key: PublicKey): String {
    // Генерируем симметричный ключ
    val aesKeyGenerator = KeyGenerator.getInstance("AES")
    aesKeyGenerator.init()
    val aesKey = aesKeyGenerator.generateKey()
    cardNumberCipher.init(Cipher.ENCRYPT_MODE, aesKey)
    val iv: ByteArray = cardNumberCipher.iv
    // Шифруем JSON симметричным ключом
    val rawJson = """{"pan":"$cardNumber"}"""
    val aesEncryptedJson = cardNumberCipher.doFinal(rawJson.toByteArray())
    // Шифруем симметричный ключ с помощью публичного ключа
    symmetricKeyCipher.init(Cipher.WRAP_MODE, key, oaepParams)
    val rsaEncryptedAesKey: ByteArray = symmetricKeyCipher.wrap(aesKey)
    val resultPackage = rsaEncryptedAesKey
    // Весь пакет кодируется base64
    return base64(resultPackage)
    }
    }

    View Slide

  53. Сложная логика
    Убираем android-зависимости
    Redmadrobot
    class CardNumberCipherImpl(private val base64: (ByteArray) -> String) : CardNumberCipher {
    override fun encrypt(cardNumber: String, key: PublicKey): String {
    // Генерируем симметричный ключ
    val aesKeyGenerator = KeyGenerator.getInstance("AES")
    aesKeyGenerator.init()
    val aesKey = aesKeyGenerator.generateKey()
    cardNumberCipher.init(Cipher.ENCRYPT_MODE, aesKey)
    val iv: ByteArray = cardNumberCipher.iv
    // Шифруем JSON симметричным ключом
    val rawJson = """{"pan":"$cardNumber"}"""
    val aesEncryptedJson = cardNumberCipher.doFinal(rawJson.toByteArray())
    // Шифруем симметричный ключ с помощью публичного ключа
    symmetricKeyCipher.init(Cipher.WRAP_MODE, key, oaepParams)
    val rsaEncryptedAesKey: ByteArray = symmetricKeyCipher.wrap(aesKey)
    val resultPackage = rsaEncryptedAesKey
    // Весь пакет кодируется base64
    return base64(resultPackage)
    }
    }

    View Slide

  54. Сложная логика
    Где-то в DI
    Redmadrobot
    @Provides
    @AppScope
    internal fun provideCardNumberCipher(): CardNumberCipher {
    return CardNumberCipherImpl(
    { bytes: ByteArray ->
    Base64.encodeToString(bytes, Base64.NO_WRAP)
    }
    )
    }

    View Slide

  55. Сложная логика
    Пишем тест
    Redmadrobot
    @Test
    fun `when encrypt data - then decrypt works correctly`() {
    // Given
    val cipher = CardNumberCipherImpl(Base64.getEncoder()::encodeToString)
    // When
    val cardNumber = "2200111552975178"
    val keyPair: KeyPair = KeyPairGenerator.getInstance("RSA").genKeyPair()
    val encryptedData = cipher.encrypt(cardNumber, keyPair.public)
    // Then
    // Разбираем исходный пакет
    val base64Decoded = Base64.getDecoder().decode(encryptedData)
    val encryptedAesKey = ByteArray(AES_KEY_LENGTH)
    System.arraycopy(base64Decoded, 0, encryptedAesKey, 0, AES_KEY_LENGTH)
    val iv = ByteArray(IV_LENGTH)
    System.arraycopy(base64Decoded, AES_KEY_LENGTH, iv, 0, IV_LENGTH)
    val encryptedDataLength = base64Decoded.size - AES_KEY_LENGTH - IV_LENGTH
    val aesEncryptedData = ByteArray(encryptedDataLength)
    System.arraycopy(base64Decoded, AES_KEY_LENGTH + IV_LENGTH, aesEncryptedData, 0, encryptedDataLength)
    // Расшифруем ключ для AES-шифрования приватным ключом RSA
    val symmetricKeyCipher = Cipher.getInstance(CardNumberCipherImpl.SYMMETRIC_KEY_ENCRYPTION_ALGORITHM)
    symmetricKeyCipher.init(Cipher.UNWRAP_MODE, keyPair.private, CardNumberCipherImpl.oaepParams)
    val aesKey = symmetricKeyCipher.unwrap(encryptedAesKey, "AES", Cipher.SECRET_KEY)
    // Расшифруем данные с помощью расшифрованного AES-ключа
    val cardNumberCipher = Cipher.getInstance(CardNumberCipherImpl.CARD_NUMBER_ENCRYPTION_ALGORITHM)
    cardNumberCipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
    val resultedDecryptedData = String(cardNumberCipher.doFinal(aesEncryptedData))
    val expectedJson = """{"pan":"$cardNumber"}"""
    assertThat(resultedDecryptedData).isEqualTo(expectedJson)
    }

    View Slide

  56. 3
    Менять код
    без страха
    Redmadrobot

    View Slide

  57. Менять код без страха
    Когда хочется эксперимента
    Redmadrobot
    @Test
    fun `when deserialize company json - then create correct object`() {
    // Given
    val companyJson = """
    {
    "id": 52000000030,
    "name": "Wall Street English",
    "description": "Школа английского языка",
    "detailedDescription": "Школа Wall Street English является передовой",
    "companyAvatar": [
    {
    "url": "https://1047016741.rsc.cdn77.org/pp052/normal/52000000030_f648420611.jpg",
    "width": 240,
    "height": 240
    }
    ]
    }
    """.trimIndent()
    // When
    val company = fromJson(companyJson)
    // Then
    val expectedImage = Image(
    url = "https://1047016741.rsc.cdn77.org/pp052/normal/52000000030_f648420611.jpg",
    width = 240,
    height = 240
    )
    assertThat(company).isNotNull
    with(company!!) {
    assertThat(id).isEqualTo(52000000030L)
    assertThat(name).isEqualTo("Wall Street English")
    assertThat(description).isEqualTo("Школа английского языка")
    assertThat(companyAvatar).containsExactly(expectedImage)
    }
    }
    Moshi

    View Slide

  58. Менять код без страха
    Зачем я написал тесты на Json
    Redmadrobot
    01
    02
    03
    Нет уверенности в библиотеке, поэтому хочется
    подстраховаться на случай возврата на Gson
    Добавление адаптеров не должно сломать сериализацю/десериализацию
    Тесты - документация на библиотеку
    04 Проще фиксить баги с обязательностью/необязательностью полей

    View Slide

  59. Менять код без страха
    Один assert на тест?
    Redmadrobot
    with(company!!) {
    assertThat(id).isEqualTo(52000000030L)
    assertThat(name).isEqualTo("Wall Street English")
    assertThat(description).isEqualTo("Школа английского языка")
    assertThat(companyAvatar).containsExactly(expectedImage)
    }

    View Slide

  60. Менять код без страха
    Код, который постоянно меняется
    или может поменяться
    Redmadrobot
    fun Double.formatAsMoney(): String {
    return "${this.formatValue()} руб."
    }
    private fun Double.formatValue(): String {
    val cashbackFormat = if (this % 1.0 == 0.0) "%.0f" else "%.2f"
    return String.format(Locale.US, cashbackFormat, this)
    }

    View Slide

  61. Менять код без страха
    Пишем тест
    Redmadrobot
    class DoubleExtTest {
    @Test
    fun `when formatting raw cashback with decimal value - then result is correct`() {
    // Given
    val rawCashBackValue = 3535.7
    // When
    val result = rawCashBackValue.formatAsMoney()
    // Then
    assertThat(result).isEqualTo("3535.70 руб.")
    }
    @Test
    fun `when formatting raw cashback without decimal value - then result is correct`() {
    // Given
    val rawCashBackValue = 42.0
    // When
    val result = rawCashBackValue.formatAsMoney()
    // Then
    assertThat(result).isEqualTo("42 руб.")
    }
    }

    View Slide

  62. Менять код без страха
    Валидация
    Redmadrobot
    object CardholderNameValidator {
    private const val MAX_CARDHOLDER_NAME_LENGTH = 19
    // Регулярка для проверки требований к имени (2 слова)
    private val namePattern: Pattern = Pattern.compile("""[\-.’A-Z]+[\s][\-.’A-Z]+$""")
    fun isValidCardholderName(name: String) =
    (name.length in 3..MAX_CARDHOLDER_NAME_LENGTH) &&
    (namePattern.matcher(name).matches())
    }

    View Slide

  63. Менять код без страха
    Параметрические тесты
    Redmadrobot
    @RunWith(Parameterized::class)
    class CardholderNameValidatorTest(private val inputName: String, private val expectedResult: Boolean) {
    companion object {
    @JvmStatic
    @Parameterized.Parameters
    fun data(): Collection> {
    return listOf(
    arrayOf("IVAN IVANOV", true),
    arrayOf("IVAN IVANOV-PETROV", true),
    arrayOf("IVANIVANOV", false),
    arrayOf("[email protected][email protected]№0v", false),
    arrayOf("IVANIVAN IVANOVIVANOV", false),
    arrayOf(" I ", false),
    arrayOf("II", false),
    arrayOf("II VV AA", false)
    )
    }
    }
    @Test
    fun `cardholder name should be valid`() {
    assertThat(CardholderNameValidator.isValidCardholderName(inputName))
    .isEqualTo(expectedResult)
    }
    }

    View Slide

  64. 4
    CI и
    стена качества
    Redmadrobot

    View Slide

  65. CI
    CI
    Redmadrobot
    Нужно заставить разработчиков
    запускать тесты, чтобы они всегда
    были актуальны

    View Slide

  66. Что почитать?

    View Slide

  67. Что посмотреть?

    View Slide

  68. Что посмотреть?

    View Slide

  69. Пишите тесты!

    View Slide