Biblioteki do logowania dla języka Scala

Chcąc dowiedzieć się co dzieje się wewnątrz naszej aplikacji mamy dwie drogi. Pierwszym sposobem jest debugowanie. Jednak im więcej wątków w aplikacji i im bardziej komunikują się one w sposób asynchroniczny tym trudniej jest debugować. Drugim sposobem jest logowanie informacji. Najprostszym sposobem logowania informacji w Javie jest System.out.println, a w Scali upraszcza się to do println. Ale jest to złe z dwóch powodów:

  • Po pierwsze, jeśli piszemy aplikację “konsolową” (ang. command line interface, CLI) to użytkownik będzie niepotrzebnie widział nieinteresujące go informacje z wewnętrznego procesu przetwarzania.
  • Tak wypisanych informacji nie można zapisać w bazie danych ani wysłać do innego systemu.

Dlatego powstały biblioteki do logowania. Biblioteki takie pozwalają przekierować logi do pliku, zapisać je w bazie danych oraz wysłać je do dowolnego innego systemu.

Przegląd bibliotek

  • scala-logging - wygodna i wydajna biblioteka logowania opakowywująca bibliotekę SLF4J dla języka Scala. Niestety działa tylko dla Scala/JVM
  • util-logging - jest małym opakowaniem wbudowanego logowania Javy, aby uczynić go bardziej przyjaznym dla Scali. Niestety także, działa tylko dla Scala/JVM
  • scalajs-java-logging - implementacja java.logging dla Scala.js. Wspiera Scala.js w wersji 0.6.x i 1.0.x
  • airframe-log - biblioteka do ulepszania logowania aplikacji Scala z kolorami i lokalizacjami kodów źródłowych. Wspiera Scala.js w wersji 0.6.x i 1.0.x
  • slogging - biblioteka logowania zgodna z scala-logging (i SLF4J) oparta na makrach dla Scala/JVM, Scala.js (wersja 0.6.x) i Scala Native
  • scribe - praktyczny szkielet logowania, który nie wymaga żadnej innej struktury logowania i może być w pełni skonfigurowany programowo. Wspiera Scala.js w wersji 0.6.x oraz Scala Native.

I konkretne próby zastosowania

scala-logging

Jest to najprawdopodobniej najpopularniejsza biblioteka do logowania w języku Scala. Niestety jej wadą jest to, że działa tylko dla JVM.

W pliku build.sbt dodajemy bibliotekę do wspólnych zależności:

  libraryDependencies ++= Seq(
    "com.typesafe.scala-logging" %% "scala-logging" % "3.9.0",
  ),

Scala-logging można używać na dwa sposoby. Za pomocą traitów StrictLogging i LazyLogging. Oba traity tworzą zmienną logger, która jest loggerem.

Trait StrictLogging inicjalizuje logger w momencie utworzenia klasy:

package com.typesafe.scalalogging

import org.slf4j.LoggerFactory

trait StrictLogging {
  protected val logger: Logger = Logger(LoggerFactory.getLogger(getClass.getName))
}
package pl.writeonly.re.shared

import com.typesafe.scalalogging.StrictLogging

object StrictLoggingCore extends Core with StrictLogging {
  def apply(arg: String): Unit = {
    logger.info(s"Hello Scala $arg!")
  }
}

Trait LazyLogging inicjalizuje logger w momencie pierwszego użycia loggera:

package com.typesafe.scalalogging

import org.slf4j.LoggerFactory

trait LazyLogging {
  @transient
  protected lazy val logger: Logger = Logger(LoggerFactory.getLogger(getClass.getName))
}
package pl.writeonly.re.shared

import slogging.LazyLogging

object LazyLoggingCore extends Core with LazyLogging {
  def apply(arg: String): Unit = {
    logger.info(s"Hello Scala $arg!")
  }
}

Łączymy wszystko w obiekcie Core:

package pl.writeonly.re.shared

trait Core {
  def apply(arg: String): Unit
}

object Core extends Core {
  override def apply(arg: String): Unit = {
    StrictLoggingCore(arg)
    LazyLoggingCore(arg)
  }
}

Tworzymy test:

package pl.writeonly.re.shared

import utest._

object CoreTest extends TestSuite {
  override val tests: Tests = Tests {
    'core - {
      Core("Awesome")
    }
  }
}

Wywołujemy:

sbt clean re/test

I wszystko się wysypuje, bo scala-logging wspiera tylko JVM.

slogging

Jest to przepisana biblioteka scala-logging, która działa dla Scala Native, Scala.js oraz oczywiście Scala/JVM.

W pliku build.sbt dodajemy bibliotekę do wspólnych zależności:

  libraryDependencies ++= Seq(
    "biz.enef" %%% "slogging" % SloggingVersion,
  ),

Slogging używa się identycznie jak scala-logging.

Trait StrictLogging inicjalizuje logger w momencie utworzenia klasy:

package slogging

trait StrictLogging extends LoggerHolder {
  protected val logger : Logger = LoggerFactory.getLogger(loggerName)
}
package pl.writeonly.re.shared

import slogging.StrictLogging

object StrictLoggingCore extends Core with StrictLogging {
  def apply(arg: String): Unit = {
    logger.info(s"Hello Scala $arg!")
  }
}

Trait LazyLogging inicjalizuje logger w momencie pierwszego użycia loggera:

package slogging

trait LazyLogging extends LoggerHolder {
  protected lazy val logger = LoggerFactory.getLogger(loggerName)
}
package pl.writeonly.re.shared

import slogging.LazyLogging

object LazyLoggingCore extends Core with LazyLogging {
  def apply(arg: String): Unit = {
    logger.info(s"Hello Scala $arg!")
  }
}

Łączymy wszystko w obiekcie Core:

package pl.writeonly.re.shared

trait Core {
  def apply(arg: String): Unit
}

object Core extends Core {
  override def apply(arg: String): Unit = {
    StrictLoggingCore(arg)
    LazyLoggingCore(arg)
  }
}

Tworzymy test:

package pl.writeonly.re.shared

import utest._

object CoreTest extends TestSuite {
  override val tests: Tests = Tests {
    'core - {
      Core("Awesome")
    }
  }
}

Wywołujemy:

sbt clean re/test

I wszystko działa!

Scribe

W pliku build.sbt dodajemy bibliotekę do wspólnych zależności:

  libraryDependencies ++= Seq(
    "com.outr" %%% "scribe" % ScribeVersion,
  ),

Scribe można używać na dwa sposoby. Za pomocą traitu Logging oraz za pomocą obiektu pakietu scribe.

Trait Logging w prostu sposób tworzy logger dla każdej instancji klasy:

trait Logging {
  protected def loggerName: String = getClass.getName

  protected def logger: Logger = Logger(loggerName)
}
package pl.writeonly.re.shared

import scribe.Logging

object LoggingCore extends Core with Logging {
  override def apply(arg: String): Unit = {
    logger.info(s"Hello Scala $arg!")
  }
}

Obiekt pakietu scribe zawiera magię opartą na makrach, dlatego dziedziczenie nie jest potrzebne:

package object scribe extends LoggerSupport {
  lazy val lineSeparator: String = System.getProperty("line.separator")

  protected[scribe] var disposables = Set.empty[() => Unit]

  override def log[M](record: LogRecord[M]): Unit = Logger(record.className).log(record)

  def dispose(): Unit = disposables.foreach(d => d())

  implicit class AnyLogging(value: Any) {
    def logger: Logger = Logger(value.getClass.getName)
  }

  def async[Return](f: => Return): Return = macro Macros.async[Return]

  def future[Return](f: => Return): Future[Return] = macro Macros.future[Return]

  object Execution {
    implicit def global: ExecutionContext = macro Macros.executionContext
  }
}
package pl.writeonly.re.shared

object ScribeCore extends Core {
  def apply(arg: String): Unit = {
    scribe.info(s"Hello Scala $arg!")
  }
}

Łączymy wszystko obiektem Core:

package pl.writeonly.re.shared

trait Core {
  def apply(arg: String): Unit
}

object Core extends Core {
  override def apply(arg: String): Unit = {
    LoggingCore(arg)
    ScribeCore(arg)
  }
}

Tworzymy test:

package pl.writeonly.re.shared

import utest._

object CoreTest extends TestSuite {
  override val tests: Tests = Tests {
    'core - {
      Core("Awesome")
    }
  }
}

Wywołujemy:

sbt clean re/test

Niestety pojawia się błąd:

[error] cannot link: @java.util.Calendar$::getInstance_java.util.Calendar
[error] cannot link: @java.util.Calendar::setTimeInMillis_i64_unit
[error] unable to link
[error] (re / Nativetest / nativeLink) unable to link

Podsumowanie

Jak zwykle składnia Scali pozwala zapisać te same rzeczy prościej niż w Javie, jednocześnie dzięki temu można wymusić konwencję tworzenia loggerów na etapie kompilacji. Dzięki temu nie mamy w kodzie loggerów o nazwach innych niż loggger jak np. LOGGER lub LOG.

Ciągła integracja, ciągła kontrola, ciągła Scala

W poprzednich wpisach zbudowaliśmy ogromną komendę do analizy statycznej i dynamicznej kodu projektu oraz generacji raportów. Jednak wykonanie tej komendy trwa. A programiści nie lubią czekać.

Istnieje podejrzenie graniczące z pewnością, że programiści pracujący przy projekcie będą wywoływać komendę fragmentarycznie, a całą komendę tylko przed wysłaniem kodu do repozytorium. O ile i tego nie zapomną lub zignorują.

W scentralizowanych systemach kontroli wersji takich jak SVN lub CVS problem ten rozwiązywano za pomocą hooków po stronie klienta (tj programisty). Np. nie można było zrobić commita, jeśli kod nie był sformatowany, a pokrycie kodu testami na odpowiednio wysokim poziomie. Nie było to jednak dobre rozwiązanie ponieważ każdą walidację po stronie klienta można oszukać, obejść i/lub wyłączyć.

Dziś istnieją zdecentralizowane systemy kontroli wersji jak Git czy Mercurial. Pozwalają one w łatwy sposób tworzyć feature branche, dzięki czemu kod nie jest wysyłany bezpośrednio do głównej gałęzi repozytorium. Feature branche nie muszą zawierać sformatowanego kodu, nie muszą się nawet kompilować.

Jednocześnie chcielibyśmy mieć pewność, że w momencie łączenia feature branch z główną gałęzią repozytorium, kod zawarty w feature branchy działa poprawnie i spełnia standardy zdefiniowane w projekcie. Rozwiązaniem jest tutaj serwer ciągłej integracji.

Serwer ciągłej integracji i zamieszanie ze słownictwem

Serwer ciągłej integracji jest to serwer konfigurowany skryptem, zwanym także pipeline. Większość serwerów, w zależności od konfiguracji jest wstanie robić trzy rzeczy:

  • ciągłą integrację
  • ciągłe dostarczanie
  • ciągłe wdrażanie

Ciągła integracja (ang. Continuous Integration, CI) jest to proces, który powinien wykonać się po każdym commicie wysłanym do zdalnego repozytorium kodu źródłowego. W jego skład wchodzą:

  • sprawdzenie poprawności formatowania
  • analiza statyczna
  • kompilacja
  • analiza dynamiczna (testy jednostkowe i integracyjne)
  • generowanie raportów

i wszystko inne co zostanie uznane za słuszne dla pojedynczego commitu.

Ciągłe dostarczanie (ang. Continuous Delivery, CD) jest to proces, który powinien wykonać się po każdym commicie (zwykle merge’u) do gałęzi głównej (np. master/develop). Składa się ze wszystkich etapów ciągłej integracji plus dodatkowo:

  • nadania numeru wersji (głównie w przypadku bibliotek)
  • zbudowaniu paczki wykonywalnej (biblioteki, mikroserwisu, aplikacji)
  • wdrożeniu na serwer developerski (w przypadku mikroserwisów i aplikacji) i uruchomieniu testów systemowych oraz akceptacyjnych, a następnie wdrożeniu na serwer testowy/demonstracyjny
  • opublikowaniu w repozytorium artefaktów (głównie w przypadku bibliotek)

Oczywiście jest to tylko jedna z wielu wersji procesu. Możliwe punkty zmiany to np.:

  • polityka firmy może zakładać, że numery wersji bibliotek powinny być nadawane ręcznie
  • aplikacja jest na tyle duża, że niemożliwością jest uruchamianie wszystkich testów po każdej zmianie

Ciągłe wdrażanie (ang. Continuous Deployment) jest rozszerzeniem procesu ciągłego dostarczania dla aplikacji i zawiera tylko jeden dodatkowy punkt, zaufanie. Zaufanie, że:

  • aplikacja została odpowiednio przetestowana
  • jeśli włączy się alarm, sygnalizujący błąd w aplikacji, to ktoś się nim zajmie

Dodatkowy krok polega na automatycznym wdrażaniu wydanej aplikacji na serwer produkcyjny.

Teoretycznie możnaby wprowadzać podział na serwery CI i serwery CD. Jednak jeśli z poziomu konfiguracji serwera CI mamy dostęp do basha, lub możemy pisać wtyczki w innych językach programowania, to z łatwością możemy zamienić serwer CI w serwer CD. Łatwo więc zauważyć że granica jest tutaj bardzo płynna.

Wybór serwera CI/CD

W świecie Javy jeśli ktoś mówi o serwerze CI zwykle ma na myśli Jenkinsa. Dostępnych jest jednak wiele serwerów ciągłej integracji. Chcąc jednak jak najszybciej (najprościej) pokazać zalety ciągłej integracji należy wybrać oprogramowanie darmowe i dodatkowo dostępne jako usługa (ang. Software as a Service, SaaS). Dobrze także, aby po wyjęciu z pudełka wspierało używane przez nas języki programowania.
Przy takich założeniach wybór padł na dwa serwisy:

  • popularniejszy Travis CI używający kontenerów z Ubuntu
  • młodszy CircleCI używający kontenerów z Debianem

Niestety nie udało mi się skonfigurować CircleCI dla języka ScalaNative. Problemem były zależności dla Debiana.

Dodatkowo przydatne są także serwisy agregujące raporty z pokrycia kodu testami. Ja znalazłem dwa działające jako serwis:

oba są darmowe dla projektów opensource.

Konfiguracja generowania raportów

Do pliku project/plugins.sbt dodajemy dwie wtyczki:

addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.5.1")
addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.2.7")

Konfiguracja projektu dla Travis Ci

TravisCi jest konfigurowany za pomocą pliku .travis.yml.

Najpierw wybieramy język programowania, jego wersję, wersję Ubuntu oraz wersję Javy:

language: scala
scala: 2.11.12
dist: xenial
jdk: openjdk8

Niestety openjdk-8-jdk nie jest domyślnie zainstalowane na Ubuntu w wersji xenial. Na szczęście możemy doinstalować potrzebną nam wersję Javy z pakietów Ubuntu. Pozostałe pakiety są dla ScalaNative

addons:
  apt:
    packages:
      - openjdk-8-jdk
      - libunwind-dev
      - libgc-dev
      - libre2-dev
      - clang-6.0

Niestety to nie wystarcza i musimy podmienić wersję Javy w zmiennej środowiskowej PATH:

env:
  global:
    - JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64/
    - PATH=$JAVA_HOME/bin:$PATH

Włączamy cache dla folderów zawierających zależności:

cache:
  directories:
    - $HOME/.sbt
    - $HOME/.ivy2/cache

Uruchamiamy analizę statyczną i analizę dynamiczną kodu oraz generujemy raport z testów:

script:
  - sbt 'scalafix --check' 'test:scalafix --check' 'it:scalafix --check' &&
    sbt scalafmtSbtCheck scalafmtCheck test:scalafmtCheck it:scalafmtCheck &&
    sbt clean re/compile re/test:compile re/it:compile &&
    sbt coverage reJS/test reJVM/test reJS/it:test reJVM/it:test coverageReport &&
    sbt coverageAggregate &&
    sbt scalastyle test:scalastyle it:scalastyle &&
    sbt scapegoat cpd stats

Przesyłamy raport do usług agregujących wyniki testów:

after_success:
  - sbt coveralls
  - bash <(curl -s https://codecov.io/bash)

Pełny plik konfiguracyjny .travis.yml:

language: scala
scala: 2.11.12
dist: xenial
jdk: openjdk8

addons:
  apt:
    packages:
      - openjdk-8-jdk
      - libunwind-dev
      - libgc-dev
      - libre2-dev
      - clang-6.0

env:
  global:
    - JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64/
    - PATH=$JAVA_HOME/bin:$PATH

cache:
  directories:
    - $HOME/.ivy2/cache
    - $HOME/.sbt

script:
  - sbt 'scalafix --check' 'test:scalafix --check' 'it:scalafix --check' &&
    sbt scalafmtSbtCheck scalafmtCheck test:scalafmtCheck it:scalafmtCheck &&
    sbt clean re/compile re/test:compile re/it:compile &&
    sbt coverage reJS/test reJVM/test reJS/it:test reJVM/it:test coverageReport &&
    sbt coverageAggregate &&
    sbt scalastyle test:scalastyle it:scalastyle &&
    sbt scapegoat cpd stats

after_success:
  - sbt coveralls
  - bash <(curl -s https://codecov.io/bash)

Podsumowanie

Uważny czytelnik może zauważyć, że nie wywołuje testów dla ScalaNative. Mimo zainstalowania wszystkich pakietów wywołanie testów dla ScalaNative kończy się błędem:

[error] /usr/bin/ld: warning: libunwind.so.8, needed by /usr/bin/../lib/gcc/x86_64-linux-gnu/5.4.0/../../../x86_64-linux-gnu/libunwind-x86_64.so, may conflict with libunwind.so.1
[error] /usr/bin/ld: /home/travis/build/writeonly/resentiment/re/native/target/scala-2.11/native/lib/gc/immix/Heap.c.o: undefined reference to symbol '_Ux86_64_getcontext'
[error] //usr/lib/x86_64-linux-gnu/libunwind.so.8: error adding symbols: DSO missing from command line
[error] clang: error: linker command failed with exit code 1 (use -v to see invocation)
[info] Linking native code (immix gc) (184 ms)
[info] Starting process '/home/travis/build/writeonly/resentiment/re/native/target/scala-2.11/re-out' on port '32951'.
Exception in thread "Thread-386" java.io.IOException: Cannot run program "/home/travis/build/writeonly/resentiment/re/native/target/scala-2.11/re-out": error=2, No such file or directory
	at java.lang.ProcessBuilder.start(ProcessBuilder.java:1048)
	at scala.sys.process.ProcessBuilderImpl$Simple.run(ProcessBuilderImpl.scala:71)
	at scala.sys.process.ProcessBuilderImpl$AbstractBuilder.run(ProcessBuilderImpl.scala:102)
	at scala.sys.process.ProcessBuilderImpl$AbstractBuilder.$anonfun$runBuffered$1(ProcessBuilderImpl.scala:150)
	at scala.runtime.java8.JFunction0$mcI$sp.apply(JFunction0$mcI$sp.java:12)
	at scala.sys.process.ProcessLogger$$anon$1.buffer(ProcessLogger.scala:99)
	at scala.sys.process.ProcessBuilderImpl$AbstractBuilder.runBuffered(ProcessBuilderImpl.scala:150)
	at scala.sys.process.ProcessBuilderImpl$AbstractBuilder.$bang(ProcessBuilderImpl.scala:116)
	at scala.scalanative.testinterface.ComRunner$$anon$1.run(ComRunner.scala:31)
Caused by: java.io.IOException: error=2, No such file or directory
	at java.lang.UNIXProcess.forkAndExec(Native Method)
	at java.lang.UNIXProcess.<init>(UNIXProcess.java:247)
	at java.lang.ProcessImpl.start(ProcessImpl.java:134)
	at java.lang.ProcessBuilder.start(ProcessBuilder.java:1029)
	... 8 more

Jest to kolejny problem ScalaNative po braku możliwości wygenerowania pokrycia kodu oraz braku możliwości uruchomienia testów integracyjnych. Dowodzi to że ScalaNative niestety dalej jest zabawką i jeśli chce się pisać monady w języku kompilowanym natywnie należy wybrać RustLang.

Postscriptum

Poszukując serwisów CI natrafiłem na jeszcze jeden termin z kategorii Continuous cośtam.

Ciągła analiza statyczna (ang. Continuous static analysis) jest to proces podobny do ciągłej integracji, ale ograniczony tylko do jednego kroku, analizy statycznej. Cechą charakterystyczną serwerów ciągłej analizy statycznej jest posiadanie ogromnej ilości reguł według których sprawdzany jest kod. Klasycznym przykładem oprogramowania w świecie Javy jest tutaj SonarQube. Ja oczywiście poszukiwałem oprogramowania działającego jako darmowa usługa dla projektów opensorsowych i znalazłem:

Postscriptum 2

Termin ciągła analiza statyczna ułożyłem sam, jednak występujący w jego miejscu termin ciągła kontrola jakości kodu (ang. Continuous Inspection of Code Quality) jest według mnie zbyt ogólny.

Bardziej dynamiczna analiza kodu dla języka Scala - Property-based testing

Testy modułowe (jednostkowe) napisane w poście Dynamiczna analiza kodu dla projektu resentiment zawiodły. Mimo posiadania 100% pokrycia kodu dla klasy Calculator klasa ta nie działała w sposób poprawy.

To dlatego, że skupiłem się na drugiej linii definicji

Korzystanie z metryk testów, takich jak pokrycie kodu, zapewnia, że przetestowano odpowiednią ilość możliwych zachować programu

Zapominając jednocześnie o trzeciej:

Aby analiza dynamiczna programu była skuteczna, program docelowy musi być wykonany z wystarczającą ilością danych wejściowych do testów, aby uzyskać interesujące zachowanie.

Problem można rozwiązać za pomocą property-based testing

Property-based testing (Testowanie oparte na właściwościach)

Przy pisaniu normalnych testów, czyli modułowych (jednostkowych), integracyjnych i systemowych, wyznaczamy przypadki brzegowe i klasy równoważności. Chcemy by danych testowych było jak najmniej, tak by testy wykonywały się jak najszybciej.

W przypadku property-based testing jest inaczej. Tutaj zamiast wyznaczać konkretne dane wejściowe definiujemy tylko ogólne ograniczenia jakie mają spełniać dane. Na podstawie ograniczeń generowane są dane wejściowe dla testów. Dużo danych wejściowych. Dlatego testy te są wolne, chociaż testują pojedyncze moduły i jednostki.

Biblioteki dla property-based testing w języku Scala

  • ScalaCheck - pierwsza i najbardziej popularna biblioteka property-based testing. Wspiera Scala.js w wersji 0.6 i 1.0.0. Inspirowana biblioteką QuickCheck dla języka Haskell. Jeden z projektów typelevel. Posiada integracje z ScalaTest i Specs2.
  • scalaprops - druga najbardziej popularna biblioteka property-based testing. Wspiera Scala.js w wersji 0.6 i Scala Native w wersji 0.3. Posiada integrację z biblioteką Scalaz.
  • Nyaya - projekt niestety umarł. Wspierał Scala.js w wersji 0.6.

Testowanie oparte na właściwościach za pomocą ScalaProps

Ponieważ chcę utrzymać możliwość kompilacji krzyżowej (cross compilation), wybieram bibliotekę scalaprops dla testów.

Dodajemy wtyczkę sbt-scalaprops do pliku project/plugins.sbt:

addSbtPlugin("com.github.scalaprops" % "sbt-scalaprops" % "0.2.6")

Dodajemy wymagane zależności do libraryDependencies:

  libraryDependencies ++= Seq(
    "com.github.scalaprops" %%% "scalaprops" % ScalaPropsVersion % "test,it",
    "com.github.scalaprops" %%% "scalaprops-scalazlaws" % ScalaPropsVersion % "test,it",
  ),

Ponieważ testy jednostkowe powinny być szybkie, konfigurujemy scalaprops jako testy integracyjne.

Na początku musimy dodać do cross-projektu ustawienia dla scalaprops za pomocą linii:

lazy val re = crossProject(JSPlatform, JVMPlatform, NativePlatform)
  // ...
  .settings(scalapropsCoreSettings)

Następnie musimy wskazać folder, który będzie zawierać testy integracyjne:

lazy val re = crossProject(JSPlatform, JVMPlatform, NativePlatform)
  // ...
  .settings(
    unmanagedSourceDirectories in IntegrationTest ++= CrossType.Full.sharedSrcDir(baseDirectory.value, "it").toSeq
  )

Niestety Scala Native nie wspiera obecnie testów integracyjnych. Może kiedyś będzie wspierać, może jak będę miał czas sam ogarnę makra i napiszę stosownego pull-requesta. Do tego czasu będzie mi o tym przypominać zakomentowana linia:

lazy val re = crossProject(JSPlatform, JVMPlatform, NativePlatform)
  // ...
  //.nativeSettings(scalapropsNativeSettings)

Ostatecznie konfiguracja projektu wygląda następująco:

lazy val re = crossProject(JSPlatform, JVMPlatform, NativePlatform)
  .withoutSuffixFor(NativePlatform)
  .crossType(CrossType.Full)
  .settings(SharedSettings)
  .jsSettings(jsSettings)
  .jvmSettings(jvmSettings)
  .nativeSettings(nativeSettings)
  // IntegrationTest
  .configs(IntegrationTest)
  .settings(Defaults.itSettings)
  .settings(
    inConfig(IntegrationTest)(scalafixConfigSettings(IntegrationTest)),
    inConfig(IntegrationTest)(ScalafmtPlugin.scalafmtConfigSettings),
    inConfig(IntegrationTest)(scalariformItSettings),
    unmanagedSourceDirectories in IntegrationTest ++= CrossType.Full.sharedSrcDir(baseDirectory.value, "it").toSeq
  )
  .jsSettings(inConfig(IntegrationTest)(ScalaJSPlugin.testConfigSettings))
  .nativeSettings(inConfig(IntegrationTest)(Defaults.testSettings))
  // PropsTest
  .settings(scalapropsCoreSettings)
  //.nativeSettings(scalapropsNativeSettings)

W folderze re/shared/src/it/scala/ tworzymy testy dla klasy pl.writeonly.re.shared.Calculator:

package pl.writeonly.re.shared

import scalaprops.{ Property, Scalaprops }

object CalculatorIT extends Scalaprops {
  val calculator = new Calculator()

  val addition: (Int, Int) => Int = (x, y) => calculator.add(x, y)
  val additionTest = Property.forAll { (a: Int, b: Int) =>
    addition(a, b) == a + b
  }

  val multiplication: (Int, Int) => Int = (x, y) => calculator.mul(x, y)
  val multiplicationTest = Property.forAll { (a: Int, b: Int) =>
    multiplication(a, b) == a * b
  }

  val lessOrEqual: (Int, Int) => Boolean = (x, y) => calculator.leq(x, y)
  val lessOrEqualTest = Property.forAll { (a: Int, b: Int) =>
    lessOrEqual(a, b) == (a <= b)
  }
}

Wywołujemy:

sbt clean coverage reJS/it:test reJVM/it:test

I naszym oczom powinien ukazać się piękny komunikat:

pl.writeonly.re.shared.CalculatorIT$ 
+- additionTest  Falsified(0,0,[Arg(0, 18591416),Arg(0, 241819340)],LongSeed(1542137236582000128)) 4ms
+- lessOrEqualTest ................................................. Passed(50,0,LongSeed(1542137236604999936)) 11ms
`- multiplicationTest  Falsified(0,0,[Arg(0, -795557759),Arg(0, -1)],LongSeed(1542137236617999872)) 0ms
[error] falsified CalculatorIT additionTest Falsified(0,0,[Arg(0, 18591416),Arg(0, 241819340)],LongSeed(1542137236582000128))
[error] falsified CalculatorIT multiplicationTest Falsified(0,0,[Arg(0, -795557759),Arg(0, -1)],LongSeed(1542137236617999872))
[info] pl.writeonly.re.shared.CalculatorIT$ 39 ms
11 pl.writeonly.re.shared.CalculatorIT$.lessOrEqualTest 50 50
4 pl.writeonly.re.shared.CalculatorIT$.additionTest 50 50
0 pl.writeonly.re.shared.CalculatorIT$.multiplicationTest 50 50
[info] 11 pl.writeonly.re.shared.CalculatorIT$.lessOrEqualTest 50 50
[info] 4 pl.writeonly.re.shared.CalculatorIT$.additionTest 50 50
[info] 0 pl.writeonly.re.shared.CalculatorIT$.multiplicationTest 50 50
[error] Failed tests:
[error] 	pl.writeonly.re.shared.CalculatorIT
[error] (reJS / IntegrationTest / test) sbt.TestsFailedException: Tests unsuccessful

Testy nie przeszły, mamy błąd w kodzie. W związku z tym poprawiamy klasę Calculator:

package pl.writeonly.re.shared

class Calculator {
  type T = Int

  def add(a: T, b: T): T = a + b

  def mul(a: T, b: T): T = a * b

  def leq(a: T, b: T): Boolean = a < b

}

I ponownie wywołujemy:

sbt clean coverage reJS/it:test reJVM/it:test

Teraz dostajemy poprawną odpowiedź:

pl.writeonly.re.shared.CalculatorIT$ 
+- additionTest ................................................. Passed(50,0,LongSeed(1542137486777999872)) 12ms
+- lessOrEqualTest ................................................. Passed(50,0,LongSeed(1542137486793999872)) 5ms
`- multiplicationTest ................................................. Passed(50,0,LongSeed(1542137486800999936)) 5ms
[info] pl.writeonly.re.shared.CalculatorIT$ 31 ms
12 pl.writeonly.re.shared.CalculatorIT$.additionTest 50 50
5 pl.writeonly.re.shared.CalculatorIT$.lessOrEqualTest 50 50
5 pl.writeonly.re.shared.CalculatorIT$.multiplicationTest 50 50
[info] 12 pl.writeonly.re.shared.CalculatorIT$.additionTest 50 50
[info] 5 pl.writeonly.re.shared.CalculatorIT$.lessOrEqualTest 50 50
[info] 5 pl.writeonly.re.shared.CalculatorIT$.multiplicationTest 50 50

Ostatecznie moja pełna komenda do kompilacji to:

sbt scalafix test:scalafix it:scalafix && \
sbt scalafmtSbt scalafmt test:scalafmt it:scalafmt && \
sbt clean compile test:compile it:compile re/test && \
sbt coverage reJS/test reJVM/test reJS/it:test reJVM/it:test && \
sbt coverageReport && \
sbt scalastyle test:scalastyle it:scalastyle && \
sbt scapegoat cpd stats

Smutne podsumowania

Klasyczne testy modułowe (jednostkowe) nie wystarczają, ponieważ prawnie napisane testy modułowe, które pokrywają 100% kodu aplikacji, mogą być niepoprawne, jeśli dane wejściowe są źle dobrane.

Follow
Follow
Follow