Testowanie oparte na właściwościach

Magda Stożek

ImmutableMap<Character, Integer> countOccurrences(String sentence);

Przykład: Częstotliwość występowania liter

"Makak na hamaku"

'a' -> 5

'k' -> 3

' ' -> 2

'M' -> 1

'n' -> 1

'm' -> 1

'h' -> 1

'u' -> 1 

@Test
void shouldCountOneOccurrence() {
   Map<Character, Integer> map = counter.countOccurrences("Makak na hamaku");

   Assertions.assertThat(map.get('M')).isEqualTo(1);
}

Testy oparte na przykładach

@Test
void shouldCountMultipleOccurrences() {
   Map<Character, Integer> map = counter.countOccurrences("Makak na hamaku");

   Assertions.assertThat(map.get('a')).isEqualTo(5);
}

@Test
void shouldHaveNoEntryForNoOccurrence() {
   Map<Character, Integer> map = counter.countOccurrences("Makak na hamaku");

   Assertions.assertThat(map.get('Q')).isNull();
}
@Test
void shouldCountOccurrencesForEachLetter() {
   Map<Character, Integer> map = counter.countOccurrences("Makak na hamaku");

   ImmutableMap<Character, Integer> expectedMap = 
      ImmutableMap.<Character, Integer>builder()
       .put('M', 1)
       .put('a', 5)
       .put('k', 3)
       .put(' ', 2)
       .put('n', 1)
       .put('h', 1)
       .put('m', 1)
       .put('u', 1)
       .build();
   Assertions.assertThat(map).isEqualTo(expectedMap);
}

A w języku właściwości?

"Makak na hamaku"

'a' -> 5

'k' -> 3

' ' -> 2

'M' -> 1

'n' -> 1

'm' -> 1

'h' -> 1

'u' -> 1 

Biblioteki

  • Początek
    • 1999: QuickCheck (Haskell) - Koen Claessen i John Hughes
  • Potem:
    • C, C++, C#, Chicken, Clojure, Common Lisp, D, Elm, Elixir, Erlang, F#, Factor, Go, Io, Java, JavaScript, Julia, Kotlin, Logtalk, Lua, Node.js, Objective-C, OCaml, Perl, Prolog, PHP, Pony, Python, R, Racket, Ruby, Rust, Scala, Scheme, Smalltalk, Standard ML, Swift, Typescript, VB.NET, ...

Co dla Javy?

@Property
boolean sumOfAllOccurrencesShouldEqualSentenceLength(@ForAll String sentence) {
   Map<Character, Integer> occurrences = counter.countOccurrences(sentence);

   int sum = occurrences.values().stream().mapToInt(n -> n).sum();

   return sum == sentence.length();
}

Właściwość: dla każdego zdania, suma wystąpień równa się sumie znaków w zdaniu

org.opentest4j.AssertionFailedError: Property [LetterCounterPropertyTest:sumOfAllOccurrencesShouldEqualWordLength] falsified with sample ["A"]

                              |-------------------jqwik-------------------
tries = 1                     | # of calls to property
checks = 1                    | # of not rejected calls
generation-mode = RANDOMIZED  | parameters are randomly generated
after-failure = PREVIOUS_SEED | use the previous seed
seed = 7236755924723336792    | random seed to reproduce generated values
sample = ["A"]
original-sample = ["aQaIrrCelQZNqrZdnBF"]

Generatory

@Property
void someProperty(@ForAll String sentence)

@Property
void someProperty(@ForAll @StringLength(100) String sentence)

@Property
void someProperty(@ForAll @StringLength(min = 3, max = 100) String sentence)

@Property
void someProperty(@ForAll @NotEmpty String sentence)

@Property
void someProperty(@ForAll @AlphaChars @NumericChars String sentence)

@Property
void someProperty(@ForAll @AlphaChars @Chars({',', '.', ':', ';', '-'}) @Whitespace 
    String sentence)

Tekstowe

@Property
void someProperty(@ForAll Integer number)

@Property
void someProperty(@ForAll @IntRange(min = 1000, max = 1999) Integer number)

@Property
void someProperty(@ForAll @Positive Integer number)

@Property
void someProperty(@ForAll @BigRange(min = "1", max = "99.99") @Scale(2) BigDecimal price)

Liczbowe

@Property
void someProperty(@ForAll List<String> words)

@Property
void someProperty(@ForAll List<@AlphaChar String> words)

@Property
void someProperty(@ForAll @Size(10) Set<@Negative Integer> numbers)

@Property
void someProperty(@ForAll @Size(min=100, max=200) List<Integer> numbers)

Kolekcje

@Property
void someProperty(@ForAll Optional<String> title, @ForAll String name)

@Property
void someProperty(@ForAll @WithNull String title, @ForAll String name)

Optional

@Property
boolean myProperty(@ForAll("shortStrings") String string, @ForAll("10 to 99") int number) {
    String concatenated = string + number;
    return concatenated.length() > 2 && concatenated.length() < 11;
}

@Provide
Arbitrary<String> shortStrings() {
    return Arbitraries.strings().withCharRange('a', 'z').filter('q')
        .ofMinLength(1).ofMaxLength(8);
}

@Provide("10 to 99")
Arbitrary<Integer> numbers() {
    return Arbitraries.integers().between(10, 99);
}

Własne generatory

@Property
void shouldCalculateDiscount(@ForAll("membershipYears") Short years) {
    BigDecimal discount = calculator.prepareDiscount(years);
    //(...)
}

@Provide
Arbitrary<Short> membershipYears() {
    return Arbitraries.frequency(
        Tuple.of(30, 0),
        Tuple.of(60, 1),
        Tuple.of(7, 2),
        Tuple.of(3, 3)
    );
}

Częstotliwości

@Property
boolean comparingUnequalStrings(
        @ForAll @StringLength(min = 1, max = 10) String string1,
        @ForAll @StringLength(min = 1, max = 10) String string2
) {
    Assume.that(!string1.equals(string2));

    return string1.compareTo(string2) != 0;
}

Zawężenia

class Customer {
    public Customer(String name, LocalDate joinedAt) {
        this.name = name;
        this.joinedAt = joinedAt;
    }

//(...)
}

Składanie generatorów

class DiscountCalculator {

    BigDecimal calculateDiscount(Customer customer) {
        long yearsOfMembership = YEARS.between(customer.getJoinedAt(), LocalDate.now());
        switch ((int) yearsOfMembership) {
            case 0: return new BigDecimal("0");
            case 1: return new BigDecimal("0.1");
            case 2: return new BigDecimal("0.2");
            default: return new BigDecimal("0.3");
        }
    }
}
@Property
boolean discountShouldNotBeOver30percent(@ForAll("validCustomer") Customer customer) {
    BigDecimal discount = calculator.calculateDiscount(customer);
    return discount.compareTo(new BigDecimal("0.3")) <= 0;
}

@Provide
Arbitrary<Customer> validCustomer() {
    Arbitrary<String> names = Arbitraries.strings().alpha().ofMinLength(3).ofMaxLength(21);
    Arbitrary<LocalDate> joined = datesFromRange(LocalDate.of(2014, 2, 25), LocalDate.now());
    return Combinators.combine(names, joined).as(Customer::new);
}

Arbitrary<LocalDate> datesFromRange(LocalDate minDate, LocalDate maxDate) {
    long range = DAYS.between(minDate, maxDate);
    return Arbitraries.longs().between(0, range).map(n -> minDate.plus(n, DAYS));
}

Składanie generatorów

@Property
boolean discountShouldBeProportionalToMembershipYears(@ForAll("validCustomer") Customer olderCustomer, @ForAll("validCustomer") Customer newerCustomer) {
    Assume.that(olderCustomer.getJoinedAt().isBefore(newerCustomer.getJoinedAt()));

    BigDecimal discountForOlder = calculator.calculateDiscount(olderCustomer);
    BigDecimal discountForNewer = calculator.calculateDiscount(newerCustomer);
    return discountForOlder.compareTo(discountForNewer) >= 0;
}

Domyślne generatory

class MCustomerArbitraryProvider implements ArbitraryProvider {

    @Override
    public boolean canProvideFor(TypeUsage targetType) {
        return targetType.isOfType(Customer.class);
    }

    @Override
    public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
            //Arbitrary<Member> memberArbitrary = (...)
            return Collections.singleton(memberArbitrary);
    }

    //(...)
}

Domyślne generatory

META-INF/services/net.jqwik.api.providers.ArbitraryProvider:

pbt.CustomerArbitraryProvider
@ForAll("validCustomer") Customer customer
@ForAll Customer customer

Wyzwania

Trudne pytania wcześniej

Wyzwanie #1

Wymyślanie właściwości

Wyzwanie #2

Wyrocznia

Alternatywne sformułowanie

Niezmienniki

Symetria

Niedeterminizm

Wyzwanie #3

Przybliżenia

Wyzwanie #4

Dokumentowanie

Wyzwanie #5

Strategie

  • najpierw przykłady, potem właściwości
  • właściwości dla kluczowych elementów
  • właściwości uzupełnieniem dla przykładów

Podsumowanie

  • proporcje - złoty środek 
  • generatory - jak najbliżej rzeczywistości
  • wymyślanie właściwości - kreatywność/wzorce

Więcej

Pytania?

@Property
boolean endOfPresentation(@ForAll Attendee attendee) {
    Assume.that(attendee.hasQuestions());

    return attendee.questionsAsked > 0;
}