Skip to content

Latest commit

Β 

History

History
1043 lines (878 loc) Β· 29.1 KB

step-by-step.md

File metadata and controls

1043 lines (878 loc) Β· 29.1 KB

NIR kata (Solution proposal)

This proposal is documented in java but the code is available in other languages (C#, kotlin, scala, F#).

1) Validate a NIR

Based on the specifications, we already have a test list to start with :

Invalid NIRs
- 2230 // too short
- 323115935012322 // incorrect sex
- 2ab115935012322 // incorrect year
- 223ab5935012322 // incorrect month
- 223145935012322 // incorrect month 2
- 223005935012322 // incorrect month 3
- 22311xx35012322 // incorrect department
- 223119635012322 // incorrect department 2
- 2231159zzz12322 // incorrect city
- 223115935012321 // incorrect control key
- 2231159350123221 // too long

Valid NIRs
- 223115935012322
- 200029923123486
- 254031088723464
- 195017262676215
- 155053933981739
- 106099955391094

Write our first test

πŸ”΄ Let's start by a failing test (as usual in T.D.D). What happens if the client code sends an empty String?

class ValidateNIR {
	@Test
	void validate_empty_string_should_return_false() {
		assertThat(NIR.validate(""))
				.isFalse();
	}
}

Our code is not compiling. Compile error is a failing test

🟒 Make it green as fast as possible by generating production code from usage.

public class NIR {
    public static Boolean validate(String potentialNIR) {
        return false;
    }
}

πŸ”΅ Refactor the code to introduce an empty String rule.

public class NIR {
    public static Boolean validate(String potentialNIR) {
        return potentialNIR != "";
    }
}

Too short String

πŸ”΄ Add a second test case for too short String.

@Test
void validate_short_string_should_return_false() {
	assertThat(NIR.validate("2230"))
			.isFalse();
}

🟒 Add a length rule.

public class NIR {
    public static Boolean validate(String potentialNIR) {
		return potentialNIR != "" && potentialNIR.length() == 15;
    }
}

πŸ”΅ Simplify the expression and remove magic number.

public class NIR {
    private static final int VALID_LENGTH = 15;

    public static Boolean validate(String potentialNIR) {
        return potentialNIR.length() == VALID_LENGTH;
    }
}

Our test list is now looking like this:

Invalid NIRs
βœ… empty string
βœ… 2230 // too short
- 323115935012322 // incorrect sex
- 2ab115935012322 // incorrect year
- 223ab5935012322 // incorrect month
- 223145935012322 // incorrect month 2
- 223005935012322 // incorrect month 3
- 22311xx35012322 // incorrect department
- 223119635012322 // incorrect department 2
- 2231159zzz12322 // incorrect city
- 223115935012321 // incorrect control key
- 2231159350123221 // too long

Valid NIRs
- 223115935012322
- 200029923123486
- 254031088723464
- 195017262676215
- 155053933981739
- 106099955391094

Incorrect Sex

πŸ”΄ Let's add a new expectation from our tests.

@Test
void validate_with_invalid_sex_should_return_false() {
	assertThat(NIR.validate("323115935012322"))
			.isFalse();
}

🟒 Express sex validation.

public static Boolean validate(String potentialNIR) {
	return potentialNIR.length() == VALID_LENGTH
			&& (potentialNIR.charAt(0) == '1' || potentialNIR.charAt(0) == '2');
}

πŸ”΅ Extract named method for better comprehension.

public class NIR {
    private static final int VALID_LENGTH = 15;

    public static Boolean validate(String potentialNIR) {
        return validateLength(potentialNIR)
                && validateSex(potentialNIR);
    }

    private static boolean validateLength(String potentialNIR) {
        return potentialNIR.length() == VALID_LENGTH;
    }

    private static boolean validateSex(String potentialNIR) {
        return potentialNIR.charAt(0) == '1' || potentialNIR.charAt(0) == '2';
    }
}

In refactoring stage, you should always wonder how to improve test code as well. We have already some duplication in our tests. We could use parameterized tests instead of maintaining 1 test method per "test case".

With junit, we can create Parameterized Tests by using junit-jupiter-params.

class ValidateNIR {
    public static Stream<Arguments> invalidNIRs() {
        return Stream.of(
                Arguments.of("", "empty string"),
                Arguments.of("2230", "too short"),
                Arguments.of("323115935012322", "incorrect sex")
        );
    }

    @ParameterizedTest
    @MethodSource("invalidNIRs")
    void should_return_false(String input, String reason) {
        assertThat(NIR.validate(input))
                .isFalse()
                .as(reason);
    }
}

Test output is now looking like this: Parameterized test output

Incorrect Year

πŸ”΄ Continue to add feature from our test list.

public static Stream<Arguments> invalidNIRs() {
	return Stream.of(
		...
		Arguments.of("2ab115935012322", "incorrect year")
	);
}

Be careful when using Parameterized tests to move 1 test case at a time.

🟒 Make it pass by hardcoding year validation.

public class NIR {
    private static final int VALID_LENGTH = 15;

    public static Boolean validate(String potentialNIR) {
        return validateLength(potentialNIR)
                && validateSex(potentialNIR)
                && validateYear(potentialNIR);
    }

    private static boolean validateLength(String potentialNIR) {
        return potentialNIR.length() == VALID_LENGTH;
    }

    private static boolean validateSex(String potentialNIR) {
        return potentialNIR.charAt(0) == '1' || potentialNIR.charAt(0) == '2';
    }

    private static boolean validateYear(String potentialNIR) {
        return false;
    }
}

πŸ”΅ Implement year validation as expressed in specification. I choose to use Regex to check whether Year is a valid number or not.

private static boolean validateYear(String potentialNIR) {
	return potentialNIR
			.substring(1, 3)
			.matches("[0-9.]+");
}

The whole class can still be improved -> pass only the needed characters for inner validation.

public class NIR {
	private static final int VALID_LENGTH = 15;
	private static final char MALE = '1', FEMALE = '2';

	public static Boolean validate(String potentialNIR) {
		return validateLength(potentialNIR)
				&& validateSex(potentialNIR.charAt(0))
				&& validateYear(potentialNIR.substring(1, 3));
	}

	private static boolean validateLength(String potentialNIR) {
		return potentialNIR.length() == VALID_LENGTH;
	}

	private static boolean validateSex(char sex) {
		return sex == MALE || sex == FEMALE;
	}

	private static boolean validateYear(String year) {
		return isANumber(year);
	}

	private static boolean isANumber(String potentialNumber) {
		return potentialNumber.matches("[0-9.]+");
	}
}
Invalid NIRs
βœ… empty string
βœ… 2230 // too short
βœ… 323115935012322 // incorrect sex
βœ… 2ab115935012322 // incorrect year
- 223ab5935012322 // incorrect month
- 223145935012322 // incorrect month 2
- 223005935012322 // incorrect month 3
- 22311xx35012322 // incorrect department
- 223119635012322 // incorrect department 2
- 2231159zzz12322 // incorrect city
- 223115935012321 // incorrect control key
- 2231159350123221 // too long

Valid NIRs
- 223115935012322
- 200029923123486
- 254031088723464
- 195017262676215
- 155053933981739
- 106099955391094

Fast Forward invalid NIRs

Here are the iterations (You can see their details from the git history): Fast forward

Here is where we are in our test list:

Invalid NIRs
βœ… empty string
βœ… 2230 // too short
βœ… 323115935012322 // incorrect sex
βœ… 2ab115935012322 // incorrect year
βœ… 223ab5935012322 // incorrect month
βœ… 223145935012322 // incorrect month 2
βœ… 223005935012322 // incorrect month 3
βœ… 22311xx35012322 // incorrect department
βœ… 223119635012322 // incorrect department 2
βœ… 2231159zzz12322 // incorrect city
βœ… 223115935012321 // incorrect control key
βœ… 2231159350123221 // too long

Valid NIRs
- 223115935012322
- 200029923123486
- 254031088723464
- 195017262676215
- 155053933981739
- 106099955391094

Some interesting stuff made during the implementation.

  • Use lombok to define Extension methods on String
  • Use vavr to use a more functional and less imperative way of coding
@UtilityClass
public class StringExtensions {
    // Extension methods are static methods with at least 1 parameter
	// The first parameter type is the one we extend
    public static Option<Integer> toInt(String potentialNumber) {
        return isANumber(potentialNumber) // Use Option<Integer> -> equivalent to Optional since java 8
                ? some(Integer.parseInt(potentialNumber))
                : none();
    }

    private static boolean isANumber(String str) {
        return str != null && str.matches("[0-9.]+");
    }
}
  • Use String extension methods from our production code
@UtilityClass
// Reference extension classes
@ExtensionMethod(StringExtensions.class)
public class NIR {
	...

    private static boolean validateMonth(String month) {
        return validateNumber(month, x -> x > 0 && x <= 12);
    }

    private static boolean validateDepartment(String department) {
        return validateNumber(department, x -> x > 0 && (x <= 95 || x == 99));
    }

    // A generic method that parses a String then apply a validation function on it to check whether the value ensures it
	// Here is its signature: String -> (int -> bool) -> bool  
    private static boolean validateNumber(String potentialNumber, Function<Integer, Boolean> isValid) {
        return potentialNumber
                .toInt() // return an Option<Integer> (Some if something or None)
                .map(isValid) // called only if Some
                .getOrElse(false); // if none returns false
    }

    private static boolean isANumber(String potentialNumber) {
        return potentialNumber.matches("[0-9.]+");
    }
}

Passing Test Cases

Because we have used Triangulation on invalid NIRs we already have created a general implementation.

The more specific tests you write, the more the code will become generic.

All our valid NIRs are already well validated, we do not have to modify anything in our production code.

class ValidateNIR {
    public static Stream<Arguments> invalidNIRs() {
        return Stream.of(
                Arguments.of("", "empty string"),
                Arguments.of("2230", "too short"),
                Arguments.of("323115935012322", "incorrect sex"),
                Arguments.of("2ab115935012322", "incorrect year"),
                Arguments.of("223ab5935012322", "incorrect month"),
                Arguments.of("223145935012322", "incorrect month 2"),
                Arguments.of("223005935012322", "incorrect month 3"),
                Arguments.of("22311xx35012322", "incorrect department"),
                Arguments.of("223119635012322", "incorrect department 2"),
                Arguments.of("2231159zzz12322", "incorrect city"),
                Arguments.of("223115935012321", "incorrect key"),
                Arguments.of("2231159350123221", "too long")
        );
    }

    public static Stream<Arguments> validNIRs() {
        return Stream.of(
                Arguments.of("223115935012322"),
                Arguments.of("200029923123486"),
                Arguments.of("254031088723464"),
                Arguments.of("195017262676215"),
                Arguments.of("155053933981739"),
                Arguments.of("106099955391094")
        );
    }

    @ParameterizedTest
    @MethodSource("invalidNIRs")
    void should_return_false(String input, String reason) {
        assertThat(NIR.validate(input))
                .as(reason)
                .isFalse();
    }

    @ParameterizedTest
    @MethodSource("validNIRs")
    void should_return_true(String input) {
        assertThat(NIR.validate(input))
                .isTrue();
    }
}

Here is our final test list state from this stage.

Invalid NIRs
βœ… empty string
βœ… 2230 // too short
βœ… 323115935012322 // incorrect sex
βœ… 2ab115935012322 // incorrect year
βœ… 223ab5935012322 // incorrect month
βœ… 223145935012322 // incorrect month 2
βœ… 223005935012322 // incorrect month 3
βœ… 22311xx35012322 // incorrect department
βœ… 223119635012322 // incorrect department 2
βœ… 2231159zzz12322 // incorrect city
βœ… 223115935012321 // incorrect control key
βœ… 2231159350123221 // too long

Valid NIRs
βœ… 223115935012322
βœ… 200029923123486
βœ… 254031088723464
βœ… 195017262676215
βœ… 155053933981739
βœ… 106099955391094

Limit of this approach

Limit of primitive types

Know more about Primitive Obsession here

2) Fight Primitive Obsession

Let's apply "Parse Don't Validate" principle to fight "Primitive Obsession". We will use Property Based Testing in this part of the kata to design our parser.

Our parsing function must respect the below property

for all (validNir)
parseNIR(nir.toString) == nir

With parse don't validate we want to make it impossible to represent an invalid NIR in our system. Our data structures need to be immutables.

Our parser may look like this: String -> Either<ParsingError, NIR>

Create the Roundtrip property

  • Add vavr-test to do so
testImplementation("io.vavr:vavr-test:0.10.4")

πŸ”΄ Specify the property

class NIRProperties {
    private Arbitrary<NIR> validNIR = null;

    @Test
    void roundTrip() {
        Property.def("parseNIR(nir.ToString()) == nir")
                .forAll(validNIR)
                .suchThat(nir -> NIR.parse(nir.toString()).contains(nir))
                .check()
                .assertIsSatisfied();
    }
}

🟒 Make it pass.

  • Generate the NIR class
  • Handle error with a data structure: ParsingError
public record ParseError(String message) {
}

@EqualsAndHashCode
public class NIR {
    public static Either<ParsingError, NIR> parse(String input) {
        return right(new NIR());
    }

    @Override
    public String toString() {
        return "";
    }
}

class NIRProperties {
    private Arbitrary<NIR> validNIR = Arbitrary.of(new NIR());

    @Test
    void roundTrip() {
        Property.def("parseNIR(nir.ToString()) == nir") // describe the property
                .forAll(validNIR) // pass an Arbitrary / Generator to generate valid NIRs
                .suchThat(nir -> NIR.parse(nir.toString()).contains(nir)) // describe the Property predicate
                .check()
                .assertIsSatisfied();
    }
}

Type-Driven Development

πŸ”΅ Create the Sex type

  • We choose to use an enum for that
  • It is immutable by design
  • We need to work on the String representation of it
  • Each data structure will contain its own parsing method
public enum Sex {
    M(1), F(2);

    private final int value;

    Sex(int value) {
        this.value = value;
    }

    public static Either<ParsingError, Sex> parseSex(char input) {
        // vavr Pattern matching
        return Match(input).of(
                Case($('1'), right(M)),
                Case($('2'), right(F)),
                Case($(), left((new ParsingError("Not a valid sex"))))
        );
    }

    @Override
    public String toString() {
        return "" + value;
    }
}
  • Create a generator to be able to generate valid NIRs
private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());
  • Extend NIR with the new created type
@EqualsAndHashCode
public class NIR {
    private final Sex sex;

    public NIR(Sex sex) {
        this.sex = sex;
    }

    public static Either<ParsingError, NIR> parseNIR(String input) {
        return parseSex(input.charAt(0))
                .map(NIR::new);
    }

    @Override
    public String toString() {
        return sex.toString();
    }
}

class NIRProperties {
    private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());
    private final Arbitrary<NIR> validNIR =
            sexGenerator.map(NIR::new)
                    .arbitrary();

    @Test
    void roundTrip() {
        Property.def("parseNIR(nir.ToString()) == nir")
                .forAll(validNIR)
                .suchThat(nir -> NIR.parseNIR(nir.toString()).contains(nir))
                .check()
                .assertIsSatisfied();
    }
}

Design the Year type

Like for the Sex type, we design the new type with its generator.

πŸ”΄ create a generator

private final Gen<Year> yearGenerator = Gen.choose(0, 99).map(Year::fromInt); // have a private constructor
private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());
private final Arbitrary<NIR> validNIR =
        sexGenerator
                .map(NIR::new)
                // use the yearGenerator here
                .arbitrary();

🟒 To be able to use the yearGenerator, we need to have a context to be able to map into it. It is a mutable data structure that we enrich with the result of each generator. We create a Builder class for it:

@With
@Getter
@AllArgsConstructor
public class NIRBuilder {
    private final Sex sex;
    private Year year;

    public NIRBuilder(Sex sex) {
        this.sex = sex;
    }
}
  • We now adapt the NIRProperties to use this Builder
class NIRProperties {
    private final Random random = new Random();
    private final Gen<Year> yearGenerator = Gen.choose(0, 99).map(Year::fromInt);
    private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());

    private Arbitrary<NIR> validNIR =
            sexGenerator
                    .map(NIRBuilder::new)
                    .map(builder -> builder.withYear(yearGenerator.apply(random)))
                    .map(x -> new NIR(x.getSex(), x.getYear()))
                    .arbitrary();

    @Test
    void roundTrip() {
        Property.def("parseNIR(nir.ToString()) == nir")
                .forAll(validNIR)
                .suchThat(nir -> NIR.parseNIR(nir.toString()).contains(nir))
                .check()
                .assertIsSatisfied();
    }
}
  • We now have to adapt the NIR class to handle the Year in its construct
    • We will use the same Builder construct (in other languages we may use for comprehension or LinQ for example)
@EqualsAndHashCode
@AllArgsConstructor
public class NIR {
    private final Sex sex;
    private final Year year;

    public static Either<ParsingError, NIR> parseNIR(String input) {
        return parseSex(input.charAt(0))
                .map(NIRBuilder::new)
                .flatMap(builder -> right(builder.withYear(new Year(1))))
                .map(builder -> new NIR(builder.getSex(), builder.getYear()));
    }

    @Override
    public String toString() {
        return sex.toString() + year;
    }
}

πŸ”΅ We can now work on the Year type and its parser

@EqualsAndHashCode
@AllArgsConstructor
public class NIR {
    private final Sex sex;
    private final Year year;

    public static Either<ParsingError, NIR> parseNIR(String input) {
        return parseSex(input.charAt(0))
                .map(NIRBuilder::new)
                .flatMap(builder -> parseYear(input.substring(1, 3), builder))
                .map(builder -> new NIR(builder.getSex(), builder.getYear()));
    }

    private static Either<ParsingError, NIRBuilder> parseYear(String input, NIRBuilder builder) {
        return Year.parseYear(input)
                .map(builder::withYear);
    }

    @Override
    public String toString() {
        return sex.toString() + year;
    }
}

@EqualsAndHashCode
@ExtensionMethod(StringExtensions.class)
public class Year {
    private final int value;

    public Year(int value) {
        this.value = value;
    }

    public static Either<ParsingError, Year> parseYear(String input) {
        return input.toInt()
                .map(Year::new)
                .toEither(new ParsingError("year should be between 0 and 99"));
    }

    public static Year fromInt(Integer x) {
        return parseYear(x.toString())
                .getOrElseThrow(() -> new IllegalArgumentException("Year"));
    }

    @Override
    public String toString() {
        return String.format("%02d", value);
    }
}

We can check Properties generation by printing the generated nirs:

214
241
240
182
138
294
280
252
158
265
213
225
275

Fast Forward the design of other types

Here are the iterations (You can see their details from the git history): Fast forward for Type Driven

For now:

  • It is impossible to represent a NIR in an invalid state
  • We have a semantic that expresses the concepts behind NIR
  @Override
  public String toString() {
      return stringWithoutKey() + format("%02d", key());
  }

  // How the NIR is composed
  private String stringWithoutKey() {
      return sex.toString() + year + month + department + city + serialNumber;
  }

Is it enough by designing our types like this?

3) Bulletproof our code with "Mutation-based Property-Driven Development"

Let's create a property that demonstrates that an invalid NIR can never be parsed. We will generate a valid one and then mutate its string representation to create an invalid one. For that we will create some mutators.

for all (validNir)
mutate(nir.toString) == left

Create a Sex mutator

  private record Mutator(String name, Function1<NIR, Gen<String>> mutate){}

  private static Mutator sexMutator = new Mutator("Sex mutator", nir ->
          Gen.choose(3, 9)
                  .map(invalidSex -> invalidSex + nir.toString().substring(1))
  );
  • Define the property and use the mutator
class NIRMutatedProperties {
    private static final Random random = new Random();

    private record Mutator(String name, Function1<NIR, Gen<String>> func) {
        public String mutate(NIR nir) {
            return func.apply(nir).apply(random);
        }
    }

    private static Mutator sexMutator = new Mutator("Sex mutator", nir ->
            Gen.choose(3, 9)
                    .map(invalidSex -> invalidSex + nir.toString().substring(1))
    );

    private static Arbitrary<Mutator> mutators = Gen.choose(
            sexMutator).arbitrary();

    @Test
    void invalidNIRCanNeverBeParsed() {
        Property.def("parseNIR(nir.ToString()) == nir")
                .forAll(validNIR, mutators)
                .suchThat(NIRMutatedProperties::canNotParseMutatedNIR)
                .check()
                .assertIsSatisfied();
    }

    private static boolean canNotParseMutatedNIR(NIR nir, Mutator mutator) {
        return NIR.parseNIR(mutator.mutate(nir)).isLeft();
    }
}
  • Define mutators like this:
βœ… Sex Mutator
Truncate the NIR
Year Mutator
Month Mutator
Department Mutator
City Mutator
Serial Number Mutator
Key Mutator

Truncate mutator

  • Truncate a valid NIR string
  private static Mutator truncateMutator = new Mutator("Truncate mutator", nir ->
          Gen.choose(1, 13).map(size ->
                  size == 1 ? "" : nir.toString().substring(0, size - 1)
          )
  );
  • Let's use it in our property by simply adding it to the mutators
private static Arbitrary<Mutator> mutators = Gen.choose(
          sexMutator,
          truncateMutator
  ).arbitrary();
  • By using it, we can identify that we have an issue regarding parsing a String that has not the right size
    • We can now fix our production code
@EqualsAndHashCode
@AllArgsConstructor
@ExtensionMethod(StringExtensions.class)
public class NIR {
    ...
    public static Either<ParsingError, NIR> parseNIR(String input) {
        // Handle the length of the input
        return input.length() != VALID_LENGTH
                ? left(new ParsingError("Not a valid NIR: should have a length of " + input.length()))
                : parseSafely(input);
    }

    private static Either<ParsingError, NIR> parseSafely(String input) {
        return toNIR(input)
                .flatMap(nir -> checkKey(input.substring(13), nir));
    }


    private static Either<ParsingError, NIR> toNIR(String input) {
        return parseSex(input.charAt(0))
                .map(NIRBuilder::new)
                .flatMap(builder -> parseYear(input.substring(1, 3), builder))
                .flatMap(builder -> parseMonth(input.substring(3, 5), builder))
                .flatMap(builder -> parseDepartment(input.substring(5, 7), builder))
                .flatMap(builder -> parseCity(input.substring(7, 10), builder))
                .flatMap(builder -> parseSerialNumber(input.substring(10, 13), builder))
                .flatMap(builder -> parseKey(input.substring(13, 15), builder))
                .map(builder ->
                        new NIR(
                                builder.getSex(),
                                builder.getYear(),
                                builder.getMonth(),
                                builder.getDepartment(),
                                builder.getCity(),
                                builder.getSerialNumber()
                        )
                );
    }
    ...
}
βœ… Sex Mutator
βœ… Truncate the NIR
Year Mutator
Month Mutator
Department Mutator
City Mutator
Serial Number Mutator
Key Mutator

Fast Forward Mutators

Here are the iterations (you can see their details from the git history): Fast Forward Mutators

βœ… Sex Mutator
βœ… Truncate the NIR
βœ… Year Mutator
βœ… Month Mutator
βœ… Department Mutator
βœ… City Mutator
βœ… Serial Number Mutator
βœ… Key Mutator

At the end, the code looks like this:

public class Mutators {
    private static Gen<Integer> digits3Gen = Gen.frequency(
            Tuple.of(7, Gen.choose(1000, 9999)),
            Tuple.of(3, Gen.choose(1, 99))
    );

    private static Mutator sexMutator = new Mutator("Sex mutator", nir ->
            Gen.choose(3, 9).map(invalidSex -> concat(invalidSex, nir.toString().substring(1)))
    );

    private static Mutator yearMutator = new Mutator("Year mutator", nir ->
            Gen.frequency(
                    Tuple.of(7, Gen.choose(100, 999)),
                    Tuple.of(3, Gen.choose(1, 9))
            ).map(invalidYear -> concat(
                            nir.toString().charAt(0),
                            invalidYear,
                            nir.toString().substring(3)
                    )
            )
    );

    private static Mutator departmentMutator = new Mutator("Department mutator", nir ->
            Gen.frequency(
                    Tuple.of(7, Gen.choose(100, 999)),
                    Tuple.of(3, Gen.choose(96, 98))
            ).map(invalidDepartment -> concat(
                            nir.toString().substring(0, 5),
                            invalidDepartment,
                            nir.toString().substring(7)
                    )
            )
    );

    private static Mutator cityMutator = new Mutator("City mutator", nir ->
            digits3Gen.map(invalidCity -> concat(
                    nir.toString().substring(0, 7),
                    invalidCity,
                    nir.toString().substring(10))
            )
    );

    private static Mutator serialNumberMutator = new Mutator("Serial Number mutator", nir ->
            digits3Gen.map(invalidSerialNumber -> concat(
                    nir.toString().substring(0, 10),
                    invalidSerialNumber,
                    nir.toString().substring(13))
            )
    );

    private static Mutator keyMutator = new Mutator("Key mutator", nir ->
            Gen.choose(0, 97)
                    .filter(x -> x != nir.key())
                    .map(invalidKey -> concat(
                            nir.toString().substring(0, 13),
                            String.format("%02d", invalidKey)
                    ))
    );

    private static String concat(Object... elements) {
        return List.of(elements).mkString();
    }

    private static Mutator truncateMutator = new Mutator("Truncate mutator", nir ->
            Gen.choose(1, 13).map(size ->
                    size == 1 ? "" : nir.toString().substring(0, size - 1)
            )
    );

    public static Arbitrary<Mutator> mutators = Gen.choose(
            sexMutator,
            yearMutator,
            departmentMutator,
            cityMutator,
            serialNumberMutator,
            keyMutator,
            truncateMutator
    ).arbitrary();
}

class MutatedProperties {
  @Test
  void invalidNIRCanNeverBeParsed() {
    Property.def("parseNIR(nir.ToString()) == nir")
            .forAll(validNIR, mutators)
            .suchThat(MutatedProperties::canNotParseMutatedNIR)
            .check()
            .assertIsSatisfied();
  }

  private static boolean canNotParseMutatedNIR(NIR nir, Mutator mutator) {
    return NIR.parseNIR(mutator.mutate(nir)).isLeft();
  }
}

Let's conclude

Parsing

By using this approach you can mix T.D.D with Type Driven Development, Property-Based Testing and Mutations to design extremely robust code. Indeed, it can help you quickly identify edge cases in your system.