Типы vs. тесты

или

приёмы программирования, позволяющие писать меньше тестов

Арсений Жижелев, Праймтолк / zhizhelev@primetalk.ru

План

  • Введение
    • качество программ
    • тесты
    • типы
  • Примеры типов, уменьшающих потребность в тестах
    • дженерики
    • refined
    • "квитанции"
    • онтология
  • Заключение
Введение. Качество ПО

Качественная программа — (1) достигает (2) требуемого результата (3) с соблюдением заданных ограничений и (4) минимизирует целевую функцию.

Введение. Тесты
  • приёмочные тесты
  • юнит-тесты (модульные тесты), интеграционные, функциональные тесты
  • property-based тесты (проверка свойств)
  • и другие
Введение. Типы в Scala
  • DOT
  • синглетонные типы — 1, ""
  • A & B, A | B
  • типы функций A => B
  • контекстные функции Context ?=> A => B
  • полиморфные функции [A] => List[A] => B
  • функции в пространстве типов [X, Y] =>> Map[Y, X] (лямбды в пространстве типов)
Введение. Типы в Scala (пример)

            type Fibonacci[a <: Int, b <: Int, n <: Int] <: Int = n match
              case 0    => b
              case S[x] => Fibonacci[b, a + b, x]
          
реализация

            transparent inline def fibonacci[n <: Int](inline a: Int, inline b: Int): Int =
              inline erasedValue[n] match
                case _: 0    => b
                case _: S[x] =>
                  fibonacci[x](b, a + b)
          
(8 ферзей)
Типы 0. Компилятор
  • каждая строка кода проходит компиляцию
  • опечатки
  • неправильные аргументы функций
  • несовместимые типы в одном выражении

Тесты?

  • 100% покрытие
  • тесты API (изменение состава аргументов)
Типы 1. Дженерики
  • обобщённая функция широко применима
  • некоторые функции определены типом однозначно ("параметричность")
    
                      def f[A](a: A): A = ???
                    
    
                      val f: [A] => A => A = ???
                    
    
                      val identity: [A] => A => A = [A] => (a: A) => a
                    
  • универсальные функции map, flatMap, foldLeft, traverse, ...

Тесты?

  • для каждого типа данных
  • меньше кода — меньше тестов
Типы 2. sealed trait'ы и enum (АДТ)
  • точная передача семантики (bool -> enum)
  • закрытый список вариантов
  • Either[Error, A] вместо исключений

Тесты?

  • тесты исключений

Не тестируемые проблемы

  • расширение enum при рефакторинге
  • непредусмотренная обработка каких-либо случаев
Типы 2. sealed trait'ы (рельсовое программирование)
  • Either[Error, A] вместо исключений

Тесты?

  • тесты ветвлений
  • тесты обработки ошибок
Типы 3. явный Null

              val x: String | Null = null // ok

              -Yexplicit-nulls
            
  • похоже на Kotlin

Тесты?

  • проверка на null всех N функций и всех комбинаций M аргументов O(N*2^M)

              def foo(a: A|Null, b: B|Null): Int = (a, b) match
                case (null, null) => 0
                case (null,    _) => 1
                case (   _, null) => 2
                case _            => 3
              def bar(a: A, b: B): Int = 3
            
Типы 4. Refined-типы

              type PortNumber = Int Refined And[Not[Less[0]], Less[65536]]
              val a: String Refined NonEmpty = refineMV[NonEmpty]("a")
            
  • предикат проверяется на этапе компиляции
  • (для runtime'а — Either)

Тесты?

  • выхода за границы
  • проверка синтаксиса регулярных выражений, URL, IP-адресов, XPath-выражений
  • коллекция не пустая, все элементы удовлетворяют предикату
  • обработка некорректных входных данных для нетотальной функции

Дополнительные возможности

  • доказательство теорем с использованием SMT
Типы 4. Refined-типы (total)
  • функция, принимающая refined-тип, может быть сделана тотальной

def slice(n: Int Refined Positive): [A] => List[A] => List[List[A]] =
  [a] => (lst: List[a]) => 
    val (init, tail) = lst.splitAt(n)
    if tail.isEmpty then
      List(init)
    else
      init :: slice(n)(tail)
            

              // http://nikita-volkov.github.io/refined/
              slice :: Refined Positive Int -> [a] -> [[a]]
              slice n l =
                splitAt (unrefine n) l & \(a, b) -> a : slice n b            
            
  • пример Никиты Волкова (Haskell)
  • функция не содержит кода n≤0

Тесты?

  • проверка входных данных
Типы 4. Refined-типы (SMT)
  • экспериментальное расширение: вызов SMT-солвера
  • доказательство теорем об алгоритмах

Тесты?

  • корректности алгоритма
Типы 5. sealed-квитанции (*new)

в библиотеке:


                sealed trait LogReceipt // почти `Unit` 
                def log(message: String): IO[LogReceipt] =
                  IO{...}// побочный эффект — сохранение сообщения во внешнюю систему
                    .as(new LogReceipt{})
              
  • невозможно получить экземпляр квитанции без исполнения эффекта

Тесты?

  • проверка наличия сообщения в логе
  • наличие LogReceipt в типе гарантирует, что побочный эффект был выполнен (сообщение сохранено в логе)
Типы 5. sealed-квитанции 2 (generic)

                sealed trait SavedToDBReceipt[A]
                def saveToDB[A](a: A): IO[SavedToDBReceipt[A]] =
                  IO{...}// побочный эффект — сохранение в БД
                    .as(new SavedToDBReceipt[A]{})
              


                  opaque type DBGeneratedID[A] = Long
                  def saveToDB[A](a: A): IO[DBGeneratedID[A]] =
                    IO{...}// побочный эффект — сохранение в БД с возвратом ID
                      .map(id => id : DBGeneratedID[A])
                
  • универсальная квитанция
  • ID из БД => дополнительная уверенность
  • generic-параметр позволяет убедиться, что мы сохранили именно то, что хотели

Тесты?

  • проверка сохранения в БД для каждой сущности
Типы 5. sealed-квитанции 3 (несколько)
  • несколько квитанций — как агрегировать/проверить?
  • конструктор Tuple — *:
  • (библиотека type-sets)

Тесты?

  • проверка наличия результатов нескольких эффектов
Типы 5. sealed-квитанции 5 (код)

              def fewSteps(): IO[Set3[LogReceipt, SavedToDBReceipt[Person], SavedToDBReceipt[Product]]] =
                for
                  logReceipt   <- log("")
                  savedPerson  <- saveToDB(person)
                  savedProduct <- saveToDB(product)
                yield
                  Set(logReceipt, savedPerson, savedProduct)
            

              def handle(): IO[Unit] = 
                for
                  receipts <- fewSteps()
                  _        = setEquals[Set3[
                    SavedToDBReceipt[Product], 
                    LogReceipt, 
                    SavedToDBReceipt[Person]
                  ]](receipts)
                  _        = setIsASuperset[Set1[LogReceipt]]
                yeild
                  Http.SuccessOk
            
Типы 6. параметрический полиморфизм (type class'ы)

trait Monoid[T]:
  def empty: T
  def combine(t1: T, t2: T): T
            

              class Monoid t where
                mempty :: t
                mappend :: t -> t -> t
            
  • без недостатков наследования реализации
  • реализация для существующих типов (стандартных, final, библиотечных)
  • * структурированные экземпляры на основе сложных типов
  • 
    given tupleMonoid[A, B <: Tuple](using ma: Monoid[A], mb: Monoid[B]): Monoid[A *: B] = 
      new Monoid[A *: B]:
        def empty: A *: B = ma.empty *: mb.empty
        def combine(t1: A *: B, t2: A *: B): A *: B = 
          ma.combine(t1.head, t2.head) *: mb.combine(t1.tail, t2.tail)
                  
Типы 6. параметрический полиморфизм (тесты?)
  • алгоритм + тайп-класс (N x M => N + M)
  • модификация доступа к private полям (т.к. нет наследования реализации)
  • переопределение protected-методов

вклад в качество

  • чёткая "математическая" граница модулей
  • уменьшение количества кода за счёт комбинаторики
Типы 7. Типизированная онтология (1)
  • разные представления одной сущности
  • конвертация
  • полнота? корректность?

              case class Person1(name: String)
              case class Person2(id: Int, name: String)
              def convert(p2: Person2): Person1 = Person1(p2.name)
              def convert(id: Int, p1: Person1): Person2 = Person2(id, p1.name)
            
Типы 7. Типизированная онтология (2)
  • онтология — совокупность свойств, сущностей, отношений
  • проекция — структура данных, содержащая часть информации о сущности

              abstract final class Person
              object personProps extends RecordSchemaBuilder[Person]:
                val id = property[Int]("id")
                val name = property[String]("name")
                val Schema1 = fields(name)
                val Schema2 = fields(id, name)
              val bs1 = personProps.Schema1
              val bs2 = personProps.Schema2
              val person1: bs1.Values = bs1.values(("Vasya"))
              val person2: bs2.Values = bs2.values((20, "Vasya"))
              val lst1 = List(person2).projection(personProps.Schema1)
            

Тесты?

  • проверка корректности конвертации данных
  • тесты общих механизмов для каждой сущности
Типы 7. Типизированная онтология (3 Реляционная алгебра)
  • схема сущностей — Entity-Relationship
  • все операции реляционной алгебры:
    • проекция
    • полное произведение
    • выбор (фильтрация)
    • join (по внешнему ключу)
    • переименование и вычисление колонок
  • построение корректных SQL-like запросов
  • event-sourcing приложения (события сущности)
  • сквозная типизация во всех слоях
Как типы позволяют избавляться от тестов?
  • доказательство корректности программ (гарантии)
  • уменьшение количества кода (дженерики)
  • упрощение функций, уменьшение ветвления
Заключение
  • тесты — огромный вклад в качество ПО
  • развитые типы — ещё лучше, гарантии
  • тесты помогают, когда в типах не получилось выразить все ограничения
Ссылки

Спасибо за внимание

Вопросы?

Арсений Александрович Жижелев, Праймтолк / zhizhelev@primetalk.ru