diff --git a/README.md b/README.md index ae6dcf31..5ef72c62 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ A solution proposal will be published here every day during the `Advent Of Craft - [Day 12: Make your code open for extension.](solution/day12/docs/step-by-step.md) - [Day 13: Find a way to eliminate the irrelevant, and amplify the essentials of those tests.](solution/day13/docs/step-by-step.md) - [Day 14: Do not use exceptions anymore.](solution/day14/docs/step-by-step.md) - +- [Day 15: Put a code under tests.](solution/day15/docs/step-by-step.md) ## Contributors diff --git a/solution/day15/docs/challenge-done.md b/solution/day15/docs/challenge-done.md new file mode 100644 index 00000000..865c48c9 --- /dev/null +++ b/solution/day15/docs/challenge-done.md @@ -0,0 +1,49 @@ +## Day 15: Put a code under tests. + +Imagine we need to adapt the code below to support a new document template + - We want to be sure to not introduce regression / have a safety net + +### Assessing the right type of tests + +- Let's add some tests + - We have plenty of possible combinations + - We could use `ParameterizedTests` to make those assertions + +### Use Approval Testing instead + +We can use `Approval Testing` to quickly put legacy code under tests. + +It is also called : `Characterization Tests` OR `Golden Master` +- Unit testing assertions can be difficult to use and long to write +- Approval tests simplify this by taking a snapshot of the results / confirming that they have not changed at each run + +Let's set that up: +- We add [ApprovalTests](https://github.com/approvals/approvaltests.java) dependency in our pom +- Add `.gitignore` file to exclude `*.received.*` from git +- Instead, let's use the power of Approval !!! +- We can generate combinations and have only 1 test (golden master) using `CombinationApprovals.verifyAllCombinations` + +How it works: +- On the first run, 2 files are created + - `DocumentTests.combinationTests.received.txt`: the result of the call of the method under test + - `DocumentTests.combinationTests.approved.txt`: the approved version of the result (approved manually) +- The library simply compare the 2 text files, so it fails the first time you run it +- It compares the actual result and an empty file + +- We need to approve the `received` file to make the test passes + - Meaning we create the `approved` one with the result of the current production code + +### Refactoring time +- We can even improve the test by making it totally dynamic + - If we add a new Enum entry the test will fail + - Forcing us to approve the new version of the test output + +- In just a few minutes, we have successfully covered a cryptic code with robust tests + +>**Tip of the day: Choosing the right type of test can help you gather better feedback on your code.** + +### Share your experience + +How does your code look like? + +Please let everyone know in the discord. diff --git a/solution/day15/docs/img/2-files.png b/solution/day15/docs/img/2-files.png new file mode 100644 index 00000000..d9c5d24b Binary files /dev/null and b/solution/day15/docs/img/2-files.png differ diff --git a/solution/day15/docs/img/approval-testing-cheatsheet.png b/solution/day15/docs/img/approval-testing-cheatsheet.png new file mode 100644 index 00000000..c9dbefe2 Binary files /dev/null and b/solution/day15/docs/img/approval-testing-cheatsheet.png differ diff --git a/solution/day15/docs/img/code-coverage.png b/solution/day15/docs/img/code-coverage.png new file mode 100644 index 00000000..c589b098 Binary files /dev/null and b/solution/day15/docs/img/code-coverage.png differ diff --git a/solution/day15/docs/img/fail.png b/solution/day15/docs/img/fail.png new file mode 100644 index 00000000..2b2b0ded Binary files /dev/null and b/solution/day15/docs/img/fail.png differ diff --git a/solution/day15/docs/img/file-compare.png b/solution/day15/docs/img/file-compare.png new file mode 100644 index 00000000..b0adebd4 Binary files /dev/null and b/solution/day15/docs/img/file-compare.png differ diff --git a/solution/day15/docs/img/first-run.png b/solution/day15/docs/img/first-run.png new file mode 100644 index 00000000..00ffe626 Binary files /dev/null and b/solution/day15/docs/img/first-run.png differ diff --git a/solution/day15/docs/img/use-combinations.png b/solution/day15/docs/img/use-combinations.png new file mode 100644 index 00000000..5c360009 Binary files /dev/null and b/solution/day15/docs/img/use-combinations.png differ diff --git a/solution/day15/docs/snippet.png b/solution/day15/docs/snippet.png new file mode 100644 index 00000000..c91d6686 Binary files /dev/null and b/solution/day15/docs/snippet.png differ diff --git a/solution/day15/docs/step-by-step.md b/solution/day15/docs/step-by-step.md new file mode 100644 index 00000000..b0da70c3 --- /dev/null +++ b/solution/day15/docs/step-by-step.md @@ -0,0 +1,178 @@ +## Day 15: Put a code under tests. +- Imagine we need to adapt the code below to support a new document template + - We want to be sure to not introduce regression / have a safety net + +```java +public enum DocumentTemplateType { + DRP("DEER", I), + DPM("DEER", L), + ATP("AUTP", I), + ATM("AUTM", L), + SPEC("SPEC", ALL), + GLP("GLPP", I), + GLM("GLPM", L); + + private final String documentType; + private final RecordType recordType; + + DocumentTemplateType(String documentType, RecordType recordType) { + this.documentType = documentType; + this.recordType = recordType; + } + + public static DocumentTemplateType fromDocumentTypeAndRecordType(String documentType, String recordType) { + for (DocumentTemplateType dtt : DocumentTemplateType.values()) { + if (dtt.getDocumentType().equalsIgnoreCase(documentType) + && dtt.getRecordType().equals(RecordType.valueOf(recordType))) { + return dtt; + } else if (dtt.getDocumentType().equalsIgnoreCase(documentType) + && dtt.getRecordType().equals(ALL)) { + return dtt; + } + } + throw new IllegalArgumentException("Invalid Document template type or record type"); + } + + private RecordType getRecordType() { + return recordType; + } + + private String getDocumentType() { + return documentType; + } +} +``` + +- Let's add some tests + - We have plenty of possible combinations + - We could use `ParameterizedTests` to make those assertions + +```java +@Test +void given_glpp_and_individual_prospect_should_return_glpp() { + final var result = DocumentTemplateType.fromDocumentTypeAndRecordType("GLPP", "INDIVIDUAL_PROSPECT"); + assertThat(result).isEqualTo(DocumentTemplateType.GLPP); +} + +@Test +void given_glpp_and_legal_prospect_should_fail() { + assertThrows(IllegalArgumentException.class, + () -> DocumentTemplateType.fromDocumentTypeAndRecordType("GLPP", "LEGAL_PROSPECT")); +} +``` + +### Use Approval Testing instead +We can use `Approval Testing` to quickly put legacy code under tests. + +Learn more about it [here](https://understandlegacycode.com/approval-tests/). + +It is also called : `Characterization Tests` OR `Golden Master` +- Unit testing assertions can be difficult to use and long to write +- Approval tests simplify this by taking a snapshot of the results / confirming that they have not changed at each run + + +We add [ApprovalTests](https://github.com/approvals/approvaltests.java) dependency in our pom + +```xml + + 22.3.2 + + + + + com.approvaltests + approvaltests + ${approvaltests.version} + + +``` + +- Add `.gitignore` file to exclude `*.received.*` from git + +```text +### Approval exclusion ### +*.received.* +``` + +- Instead, let's use the power of Approval !!! + +![cheat sheet](img/approval-testing-cheatsheet.png) + +- We can generate combinations and have only 1 test (golden master) using `CombinationApprovals.verifyAllCombinations` + +![Use Combinations](img/use-combinations.png) + +- We use all the possible values as inputs + - It will make a cross product for the test + +```java +@Test +void combinationTests() { + CombinationApprovals.verifyAllCombinations( + DocumentTemplateType::fromDocumentTypeAndRecordType, + new String[]{"AUTP", "AUTM", "DEERPP", "DEERPM", "SPEC", "GLPP", "GLPM"}, + new String[]{"INDIVIDUAL_PROSPECT", "LEGAL_PROSPECT", "ALL"} + ); +} +``` + +- On the first run, 2 files are created + - `DocumentTests.combinationTests.received.txt`: the result of the call of the method under test + - `DocumentTests.combinationTests.approved.txt`: the approved version of the result (approved manually) + +![2 files](img/2-files.png) + +- The library simply compare the 2 text files, so it fails the first time you run it + +![Fail on the first run](img/fail.png) + +- It compares the actual result and an empty file + +![Compare files](img/file-compare.png) + +- We need to approve the `received` file to make the test passes + - Meaning we create the `approved` one with the result of the current production code + +```bash +cp src/test/java/DocumentTests.combinationTests.received.txt src/test/java/DocumentTests.combinationTests.approved.txt +``` + +### Refactoring time +- We can even improve the test by making it totally dynamic + - If we add a new Enum entry the test will fail + - Forcing us to approve the new version of the test output + +```java +@Test +void combinationTests() { + verifyAllCombinations( + DocumentTemplateType::fromDocumentTypeAndRecordType, + Arrays.stream(DocumentTemplateType.values()).map(Enum::name).toArray(String[]::new), + Arrays.stream(RecordType.values()).map(Enum::name).toArray(String[]::new) + ); +} +``` + +- In just a few minutes, we have successfully covered a cryptic code with robust tests + +![Code Coverage](img/code-coverage.png) + +> We are now ready for refactoring... 😉 + +- We refactor the production code + +```java +private static final Map mapping = + List.of(DocumentTemplateType.values()) + .toMap(v -> formatKey(v.getDocumentType(), v.getRecordType().name()), v -> v) + .merge(List.of(RecordType.values()).toMap(v -> formatKey(SPEC.name(), v.name()), v -> SPEC)); + +private static String formatKey(String documentType, String recordType) { + return documentType.toUpperCase() + "-" + recordType.toUpperCase(); +} + +public static DocumentTemplateType fromDocumentTypeAndRecordType(String documentType, String recordType) { + return mapping.get(formatKey(documentType, recordType)) + .getOrElseThrow(() -> new IllegalArgumentException("Invalid Document template type or record type")); +} +``` \ No newline at end of file diff --git a/solution/day15/pom.xml b/solution/day15/pom.xml new file mode 100644 index 00000000..1d597c36 --- /dev/null +++ b/solution/day15/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + + com.advent-of-craft + advent-of-craft2023 + 1.0-SNAPSHOT + + + documents + 1.0-SNAPSHOT + + 22.3.2 + 0.10.4 + + + + + com.approvaltests + approvaltests + ${approvaltests.version} + + + io.vavr + vavr + ${vavr.version} + + + \ No newline at end of file diff --git a/solution/day15/src/main/java/document/DocumentTemplateType.java b/solution/day15/src/main/java/document/DocumentTemplateType.java new file mode 100644 index 00000000..b97912c7 --- /dev/null +++ b/solution/day15/src/main/java/document/DocumentTemplateType.java @@ -0,0 +1,44 @@ +package document; + +import io.vavr.collection.List; +import io.vavr.collection.Map; + +public enum DocumentTemplateType { + DEERPP("DEER", RecordType.INDIVIDUAL_PROSPECT), + DEERPM("DEER", RecordType.LEGAL_PROSPECT), + AUTP("AUTP", RecordType.INDIVIDUAL_PROSPECT), + AUTM("AUTM", RecordType.LEGAL_PROSPECT), + SPEC("SPEC", RecordType.ALL), + GLPP("GLPP", RecordType.INDIVIDUAL_PROSPECT), + GLPM("GLPM", RecordType.LEGAL_PROSPECT); + + private static final Map mapping = + List.of(DocumentTemplateType.values()) + .toMap(v -> formatKey(v.getDocumentType(), v.getRecordType().name()), v -> v) + .merge(List.of(RecordType.values()).toMap(v -> formatKey(SPEC.name(), v.name()), v -> SPEC)); + + private final String documentType; + private final RecordType recordType; + + DocumentTemplateType(String documentType, RecordType recordType) { + this.documentType = documentType; + this.recordType = recordType; + } + + private static String formatKey(String documentType, String recordType) { + return documentType.toUpperCase() + "-" + recordType.toUpperCase(); + } + + public static DocumentTemplateType fromDocumentTypeAndRecordType(String documentType, String recordType) { + return mapping.get(formatKey(documentType, recordType)) + .getOrElseThrow(() -> new IllegalArgumentException("Invalid Document template type or record type")); + } + + private RecordType getRecordType() { + return recordType; + } + + private String getDocumentType() { + return documentType; + } +} \ No newline at end of file diff --git a/solution/day15/src/main/java/document/RecordType.java b/solution/day15/src/main/java/document/RecordType.java new file mode 100644 index 00000000..259b3c05 --- /dev/null +++ b/solution/day15/src/main/java/document/RecordType.java @@ -0,0 +1,16 @@ +package document; + +public enum RecordType { + INDIVIDUAL_PROSPECT("IndividualPersonProspect"), + LEGAL_PROSPECT("LegalEntityProspect"), + ALL("All"); + private final String value; + + RecordType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} \ No newline at end of file diff --git a/solution/day15/src/test/java/DocumentTests.combinationTests.approved.txt b/solution/day15/src/test/java/DocumentTests.combinationTests.approved.txt new file mode 100644 index 00000000..61a25237 --- /dev/null +++ b/solution/day15/src/test/java/DocumentTests.combinationTests.approved.txt @@ -0,0 +1,21 @@ +[DEERPP, INDIVIDUAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[DEERPP, LEGAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[DEERPP, ALL] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[DEERPM, INDIVIDUAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[DEERPM, LEGAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[DEERPM, ALL] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[AUTP, INDIVIDUAL_PROSPECT] => AUTP +[AUTP, LEGAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[AUTP, ALL] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[AUTM, INDIVIDUAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[AUTM, LEGAL_PROSPECT] => AUTM +[AUTM, ALL] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[SPEC, INDIVIDUAL_PROSPECT] => SPEC +[SPEC, LEGAL_PROSPECT] => SPEC +[SPEC, ALL] => SPEC +[GLPP, INDIVIDUAL_PROSPECT] => GLPP +[GLPP, LEGAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[GLPP, ALL] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[GLPM, INDIVIDUAL_PROSPECT] => java.lang.IllegalArgumentException: Invalid Document template type or record type +[GLPM, LEGAL_PROSPECT] => GLPM +[GLPM, ALL] => java.lang.IllegalArgumentException: Invalid Document template type or record type diff --git a/solution/day15/src/test/java/DocumentTests.java b/solution/day15/src/test/java/DocumentTests.java new file mode 100644 index 00000000..0c5678d6 --- /dev/null +++ b/solution/day15/src/test/java/DocumentTests.java @@ -0,0 +1,18 @@ +import document.DocumentTemplateType; +import document.RecordType; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.approvaltests.combinations.CombinationApprovals.verifyAllCombinations; + +class DocumentTests { + @Test + void combinationTests() { + verifyAllCombinations( + DocumentTemplateType::fromDocumentTypeAndRecordType, + Arrays.stream(DocumentTemplateType.values()).map(Enum::name).toArray(String[]::new), + Arrays.stream(RecordType.values()).map(Enum::name).toArray(String[]::new) + ); + } +} diff --git a/solution/pom.xml b/solution/pom.xml index b66661c7..3853373b 100644 --- a/solution/pom.xml +++ b/solution/pom.xml @@ -23,6 +23,7 @@ day12 day13 day14 + day15