diff --git a/README.md b/README.md index 337a5f1b..6cb2832e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ A solution proposal will be published here every day during the `Advent Of Craft - [Day 10: Dot not use "if" statement.](solution/day10/docs/step-by-step.md) - [Day 11: Gather a dependency freshness metric.](solution/day11/docs/step-by-step.md) - [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) ## Contributors diff --git a/solution/day13/docs/challenge-done.md b/solution/day13/docs/challenge-done.md new file mode 100644 index 00000000..467417f5 --- /dev/null +++ b/solution/day13/docs/challenge-done.md @@ -0,0 +1,67 @@ +## Day 13: Find a way to eliminate the irrelevant, and amplify the essentials of those tests. + +### Identifying the building technique + +In all tests, we are manipulating Articles with Comments. We want to +create them in a simpler and more maintainable way. + +We can use [`Test Data Buidler`](https://xtrem-tdd.netlify.app/Flavours/test-data-builders) pattern to achieve this +purpose. + +It will: + +- `Facilitate writing` tests by allowing easy creation of inputs or expected data. +- `Improve test maintainability` by decoupling the creation of objects in tests, and isolating it in a single + location (Single Responsibility Principle) +- Disseminate `functional understanding` in the team through the use of business terms +- Facilitate the `readability of the tests` by explaining only the data implied in the use case + +- We need to identify what is irrelevant and what is essentials in those tests: + +Irrelevant: author, comment-text, article's name and content +Essential: an article contains a comment + +### How to create a test data builder + +- Let's create a `Test Data Builder` for the `Article` + - We would like to have a fluent data builder like this: + +```java +@BeforeEach +void setup() { + article = ArticleBuilder + .anArticle() + .build(); +} +``` + +We then generate the code from usage. +We end up with a builder class with the methods. + +- We implement the builder with the build method (simplest as possible) + +- We adapt the tests +- We iterate on the builder + +### Optimizations + +- Let's improve readability of our tests by + - simplifying them + - removing duplication + +### Bonus: additional optimizations with other libs + +We could remove call to the builder from the tests by creating a DSL and using `Higher Order Function`. + +- We could also use a library to get random `String` data for our tests (they do not impact the results of the behavior and create noise) + - We can use `Instancio` to do so + +- By using it, it makes explicit that some required data do not influence the behavior + +>**Tip of the day: Your test data and assertions can be abstracted to improve readability and maintainability.** + +### Share your experience + +How does your code look like? Anything you'd like to add? + +Please let everyone know in the discord. diff --git a/solution/day13/docs/img/comment-in-the-builder.png b/solution/day13/docs/img/comment-in-the-builder.png new file mode 100644 index 00000000..9fef7196 Binary files /dev/null and b/solution/day13/docs/img/comment-in-the-builder.png differ diff --git a/solution/day13/docs/img/duplication.png b/solution/day13/docs/img/duplication.png new file mode 100644 index 00000000..877963ef Binary files /dev/null and b/solution/day13/docs/img/duplication.png differ diff --git a/solution/day13/docs/img/extract-method.png b/solution/day13/docs/img/extract-method.png new file mode 100644 index 00000000..f2736961 Binary files /dev/null and b/solution/day13/docs/img/extract-method.png differ diff --git a/solution/day13/docs/img/generate-code-from-usage.png b/solution/day13/docs/img/generate-code-from-usage.png new file mode 100644 index 00000000..50a7cd67 Binary files /dev/null and b/solution/day13/docs/img/generate-code-from-usage.png differ diff --git a/solution/day13/docs/snippet.png b/solution/day13/docs/snippet.png new file mode 100644 index 00000000..59089a62 Binary files /dev/null and b/solution/day13/docs/snippet.png differ diff --git a/solution/day13/docs/step-by-step.md b/solution/day13/docs/step-by-step.md new file mode 100644 index 00000000..94938894 --- /dev/null +++ b/solution/day13/docs/step-by-step.md @@ -0,0 +1,380 @@ +## Day 13: Find a way to eliminate the irrelevant, and amplify the essentials of those tests. + +We can use [`Test Data Buidler`](https://xtrem-tdd.netlify.app/Flavours/test-data-builders) pattern to achieve this +purpose. + +It will: + +- `Facilitate writing` tests by allowing easy creation of inputs or expected data. +- `Improve test maintainability` by decoupling the creation of objects in tests, and isolating it in a single + location (Single Responsibility Principle) +- Disseminate `functional understanding` in the team through the use of business terms +- Facilitate the `readability of the tests` by explaining only the data implied in the use case + +- We need to identify what is irrelevant and what is essentials in those tests: + +```java +class ArticleTests { + // Those constants do not influence outcomes of the behavior + public static final String AUTHOR = "Pablo Escobar"; + private static final String COMMENT_TEXT = "Amazing article !!!"; + private Article article; + + @BeforeEach + void setup() { + // Article's name and content are irrelevant as well + article = new Article( + "Lorem Ipsum", + "consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore" + ); + } + + @Test + void should_add_comment_in_an_article() throws CommentAlreadyExistException { + article.addComment(COMMENT_TEXT, AUTHOR); + + assertThat(article.getComments()).hasSize(1); + + var comment = article.getComments().get(0); + assertThat(comment.text()).isEqualTo(COMMENT_TEXT); + assertThat(comment.author()).isEqualTo(AUTHOR); + } + + @Test + void should_add_comment_in_an_article_containing_already_a_comment() throws CommentAlreadyExistException { + // Values are not relevant + var newComment = "Finibus Bonorum et Malorum"; + var newAuthor = "Al Capone"; + + // Essential to know that it already contains a comment + article.addComment(COMMENT_TEXT, AUTHOR); + article.addComment(newComment, newAuthor); + + assertThat(article.getComments()).hasSize(2); + + var lastComment = article.getComments().getLast(); + assertThat(lastComment.text()).isEqualTo(newComment); + assertThat(lastComment.author()).isEqualTo(newAuthor); + } + + @Nested + class Fail { + @Test + void when__adding_an_existing_comment() throws CommentAlreadyExistException { + article.addComment(COMMENT_TEXT, AUTHOR); + + assertThatThrownBy(() -> { + article.addComment(COMMENT_TEXT, AUTHOR); + }).isInstanceOf(CommentAlreadyExistException.class); + } + } +} +``` + +- Let's create a `Test Data Builder` for the `Article` + - We would like something like this + +```java +@BeforeEach +void setup() { + article = ArticleBuilder + .anArticle() + .build(); +} +``` + +- We then [`generate code from usage`](https://xtrem-tdd.netlify.app/Flavours/generate-code-from-usage) + +![Generate Code From Usage](img/generate-code-from-usage.png) + +- After code genrationg, we end up with a class like this + +```java +public class ArticleBuilder { + public static ArticleBuilder anArticle() { + return null; + } + + public Article build() { + return null; + } +} +``` + +- We implement the builder (simplest as possible) + +```java +public class ArticleBuilder { + public static ArticleBuilder anArticle() { + return new ArticleBuilder(); + } + + public Article build() { + return new Article( + "Lorem Ipsum", + "consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore" + ); + } +} +``` + +- We want to use it in the `should_add_comment_in_an_article_containing_already_a_comment` test + - We need to manipulate the `Builder` itself in the test and not an `Article` + +![Comment in the builder](img/comment-in-the-builder.png) + +- We adapt the tests for that + +```java +class ArticleTests { + public static final String AUTHOR = "Pablo Escobar"; + private static final String COMMENT_TEXT = "Amazing article !!!"; + private ArticleBuilder articleBuilder; + + @BeforeEach + void setup() { + articleBuilder = anArticle(); + } + + @Test + void should_add_comment_in_an_article() throws CommentAlreadyExistException { + var article = articleBuilder.build(); + article.addComment(COMMENT_TEXT, AUTHOR); + + assertThat(article.getComments()).hasSize(1); + + var comment = article.getComments().get(0); + assertThat(comment.text()).isEqualTo(COMMENT_TEXT); + assertThat(comment.author()).isEqualTo(AUTHOR); + } + + @Test + void should_add_comment_in_an_article_containing_already_a_comment() throws CommentAlreadyExistException { + var newComment = "Finibus Bonorum et Malorum"; + var newAuthor = "Al Capone"; + var article = articleBuilder + .commented() + .build(); + + article.addComment(newComment, newAuthor); + + assertThat(article.getComments()).hasSize(2); + + var lastComment = article.getComments().getLast(); + assertThat(lastComment.text()).isEqualTo(newComment); + assertThat(lastComment.author()).isEqualTo(newAuthor); + } + + @Nested + class Fail { + @Test + void when__adding_an_existing_comment() throws CommentAlreadyExistException { + var article = articleBuilder.build(); + article.addComment(COMMENT_TEXT, AUTHOR); + + assertThatThrownBy(() -> { + article.addComment(COMMENT_TEXT, AUTHOR); + }).isInstanceOf(CommentAlreadyExistException.class); + } + } +} +``` + +- We iterate on the `builder` + +```java +public class ArticleBuilder { + // Object Mother + public static final String AUTHOR = "Pablo Escobar"; + public static final String COMMENT_TEXT = "Amazing article !!!"; + private final HashMap comments; + + public ArticleBuilder() { + comments = new HashMap<>(); + } + + public static ArticleBuilder anArticle() { + return new ArticleBuilder(); + } + + public ArticleBuilder commented() { + this.comments.put(COMMENT_TEXT, AUTHOR); + return this; + } + + // Only one place from our tests that contains this construction logic + // If Article constructor changes, we just have to change this Builder and not the 1000 related tests... + public Article build() throws CommentAlreadyExistException { + var article = new Article( + "Lorem Ipsum", + "consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore" + ); + for (Map.Entry comment : comments.entrySet()) { + article.addComment(comment.getKey(), comment.getValue()); + } + return article; + } +} +``` + +- Let's improve readability of our tests by + - simplifying them + - removing duplication + +- We extract the `assertion` method in a method + +![Extract comment assertion](img/extract-method.png) + +- Our IDE detects the duplication and help us + +![Duplication](img/duplication.png) + +- Our tests now look like this + +```java +class ArticleTests { + private ArticleBuilder articleBuilder; + + @BeforeEach + void setup() { + articleBuilder = anArticle(); + } + + @Test + void should_add_comment_in_an_article() throws CommentAlreadyExistException { + final var article = articleBuilder.build(); + article.addComment(ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR); + + assertThat(article.getComments()).hasSize(1); + assertComment(article.getComments().get(0), ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR); + } + + @Test + void should_add_comment_in_an_article_containing_already_a_comment() throws CommentAlreadyExistException { + final var newComment = "Finibus Bonorum et Malorum"; + final var newAuthor = "Al Capone"; + + var article = articleBuilder + .commented() + .build(); + + article.addComment(newComment, newAuthor); + + assertThat(article.getComments()).hasSize(2); + assertComment(article.getComments().getLast(), newComment, newAuthor); + } + + @Nested + class Fail { + @Test + void when__adding_an_existing_comment() throws CommentAlreadyExistException { + final var article = articleBuilder.build(); + article.addComment(ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR); + + assertThatThrownBy(() -> { + article.addComment(ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR); + }).isInstanceOf(CommentAlreadyExistException.class); + } + } + + private static void assertComment(Comment comment, String commentText, String author) { + assertThat(comment.text()).isEqualTo(commentText); + assertThat(comment.author()).isEqualTo(author); + } +} +``` + +### Bonus + +We could remove call to the builder from the tests by creating a DSL and using `Higher Order Function`. + +```java +class ArticleBonusTests { + private Article article; + + @Test + void should_add_comment_in_an_article() throws CommentAlreadyExistException { + when(article -> article.addComment(COMMENT_TEXT, AUTHOR)); + then(article -> { + assertThat(article.getComments()).hasSize(1); + assertComment(article.getComments().get(0), COMMENT_TEXT, AUTHOR); + }); + } + + @Test + void should_add_comment_in_an_article_containing_already_a_comment() throws Throwable { + final var newComment = create(String.class); + final var newAuthor = create(String.class); + + when(ArticleBuilder::commented, article -> article.addComment(newComment, newAuthor)); + then(article -> { + assertThat(article.getComments()).hasSize(2); + assertComment(article.getComments().getLast(), newComment, newAuthor); + }); + } + + @Nested + class Fail { + @Test + void when__adding_an_existing_comment() throws CommentAlreadyExistException { + var article = anArticle() + .commented() + .build(); + + assertThatThrownBy(() -> { + article.addComment(article.getComments().get(0).text(), article.getComments().get(0).author()); + }).isInstanceOf(CommentAlreadyExistException.class); + } + } + + private static void assertComment(Comment comment, String commentText, String author) { + assertThat(comment.text()).isEqualTo(commentText); + assertThat(comment.author()).isEqualTo(author); + } + + private void when(ArticleBuilder articleBuilder, ThrowingConsumer
act) throws CommentAlreadyExistException { + article = articleBuilder.build(); + act.accept(article); + } + + private void when(ThrowingConsumer
act) throws CommentAlreadyExistException { + when(anArticle(), act); + } + + private void when(Function options, ThrowingConsumer
act) throws Throwable { + when(options.apply(anArticle()), act); + } + + private void then(ThrowingConsumer
act) { + act.accept(article); + } +} +``` + +- We could also use a library to get random `String` data for our tests (they do not impact the results of the behavior and create noise) + - We can use `Instancio` to do so + +```xml + + org.instancio + instancio-junit + ${instancio-junit.version} + test + +``` + +- By using it, it makes explicit that some required data do not influence the behavior + +```java +@Test +void should_add_comment_in_an_article_containing_already_a_comment() throws Throwable { + final var newComment = create(String.class); + final var newAuthor = create(String.class); + + when(ArticleBuilder::commented, article -> article.addComment(newComment, newAuthor)); + then(article -> { + assertThat(article.getComments()).hasSize(2); + assertComment(article.getComments().getLast(), newComment, newAuthor); + }); +} +``` diff --git a/solution/day13/pom.xml b/solution/day13/pom.xml new file mode 100644 index 00000000..7e431aa3 --- /dev/null +++ b/solution/day13/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.advent-of-craft + advent-of-craft2023 + 1.0-SNAPSHOT + + + blog-part2 + 1.0-SNAPSHOT + + 3.0.0 + + + + + org.instancio + instancio-junit + ${instancio-junit.version} + test + + + + \ No newline at end of file diff --git a/solution/day13/src/main/java/blog/Article.java b/solution/day13/src/main/java/blog/Article.java new file mode 100644 index 00000000..c96b8772 --- /dev/null +++ b/solution/day13/src/main/java/blog/Article.java @@ -0,0 +1,37 @@ +package blog; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +public class Article { + private final String name; + private final String content; + private final ArrayList comments; + + public Article(String name, String content) { + this.name = name; + this.content = content; + this.comments = new ArrayList<>(); + } + + private void addComment( + String text, + String author, + LocalDate creationDate) throws CommentAlreadyExistException { + var comment = new Comment(text, author, creationDate); + + if (comments.contains(comment)) { + throw new CommentAlreadyExistException(); + } else comments.add(comment); + } + + public void addComment(String text, String author) throws CommentAlreadyExistException { + addComment(text, author, LocalDate.now()); + } + + public List getComments() { + return comments; + } +} + diff --git a/solution/day13/src/main/java/blog/Comment.java b/solution/day13/src/main/java/blog/Comment.java new file mode 100644 index 00000000..c6b25bfc --- /dev/null +++ b/solution/day13/src/main/java/blog/Comment.java @@ -0,0 +1,6 @@ +package blog; + +import java.time.LocalDate; + +public record Comment(String text, String author, LocalDate creationDate) { +} diff --git a/solution/day13/src/main/java/blog/CommentAlreadyExistException.java b/solution/day13/src/main/java/blog/CommentAlreadyExistException.java new file mode 100644 index 00000000..1eaca1d1 --- /dev/null +++ b/solution/day13/src/main/java/blog/CommentAlreadyExistException.java @@ -0,0 +1,4 @@ +package blog; + +public class CommentAlreadyExistException extends Exception { +} \ No newline at end of file diff --git a/solution/day13/src/test/java/blog/ArticleBonusTests.java b/solution/day13/src/test/java/blog/ArticleBonusTests.java new file mode 100644 index 00000000..437c3162 --- /dev/null +++ b/solution/day13/src/test/java/blog/ArticleBonusTests.java @@ -0,0 +1,73 @@ +package blog; + +import org.assertj.core.api.ThrowingConsumer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.function.Function; + +import static blog.ArticleBuilder.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.instancio.Instancio.create; + +class ArticleBonusTests { + private Article article; + + @Test + void should_add_comment_in_an_article() throws CommentAlreadyExistException { + when(article -> article.addComment(COMMENT_TEXT, AUTHOR)); + then(article -> { + assertThat(article.getComments()).hasSize(1); + assertComment(article.getComments().get(0), COMMENT_TEXT, AUTHOR); + }); + } + + @Test + void should_add_comment_in_an_article_containing_already_a_comment() throws Throwable { + final var newComment = create(String.class); + final var newAuthor = create(String.class); + + when(ArticleBuilder::commented, article -> article.addComment(newComment, newAuthor)); + then(article -> { + assertThat(article.getComments()).hasSize(2); + assertComment(article.getComments().getLast(), newComment, newAuthor); + }); + } + + @Nested + class Fail { + @Test + void when_adding_an_existing_comment() throws CommentAlreadyExistException { + var article = anArticle() + .commented() + .build(); + + assertThatThrownBy(() -> { + article.addComment(article.getComments().get(0).text(), article.getComments().get(0).author()); + }).isInstanceOf(CommentAlreadyExistException.class); + } + } + + private static void assertComment(Comment comment, String commentText, String author) { + assertThat(comment.text()).isEqualTo(commentText); + assertThat(comment.author()).isEqualTo(author); + } + + private void when(ArticleBuilder articleBuilder, ThrowingConsumer
act) throws CommentAlreadyExistException { + article = articleBuilder.build(); + act.accept(article); + } + + private void when(ThrowingConsumer
act) throws CommentAlreadyExistException { + when(anArticle(), act); + } + + private void when(Function options, ThrowingConsumer
act) throws Throwable { + when(options.apply(anArticle()), act); + } + + private void then(ThrowingConsumer
act) { + act.accept(article); + } +} \ No newline at end of file diff --git a/solution/day13/src/test/java/blog/ArticleBuilder.java b/solution/day13/src/test/java/blog/ArticleBuilder.java new file mode 100644 index 00000000..f1d49c83 --- /dev/null +++ b/solution/day13/src/test/java/blog/ArticleBuilder.java @@ -0,0 +1,34 @@ +package blog; + +import java.util.HashMap; +import java.util.Map; + +public class ArticleBuilder { + public static final String AUTHOR = "Pablo Escobar"; + public static final String COMMENT_TEXT = "Amazing article !!!"; + private final HashMap comments; + + public ArticleBuilder() { + comments = new HashMap<>(); + } + + public static ArticleBuilder anArticle() { + return new ArticleBuilder(); + } + + public ArticleBuilder commented() { + this.comments.put(COMMENT_TEXT, AUTHOR); + return this; + } + + public Article build() throws CommentAlreadyExistException { + var article = new Article( + "Lorem Ipsum", + "consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore" + ); + for (Map.Entry comment : comments.entrySet()) { + article.addComment(comment.getKey(), comment.getValue()); + } + return article; + } +} diff --git a/solution/day13/src/test/java/blog/ArticleTests.java b/solution/day13/src/test/java/blog/ArticleTests.java new file mode 100644 index 00000000..0cd9ddae --- /dev/null +++ b/solution/day13/src/test/java/blog/ArticleTests.java @@ -0,0 +1,55 @@ +package blog; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static blog.ArticleBuilder.anArticle; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ArticleTests { + @Test + void should_add_comment_in_an_article() throws CommentAlreadyExistException { + final var article = anArticle().build(); + article.addComment(ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR); + + assertThat(article.getComments()).hasSize(1); + assertComment(article.getComments().get(0), ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR); + } + + @Test + void should_add_comment_in_an_article_containing_already_a_comment() throws CommentAlreadyExistException { + final var newComment = "Finibus Bonorum et Malorum"; + final var newAuthor = "Al Capone"; + + var article = anArticle() + .commented() + .build(); + + article.addComment(newComment, newAuthor); + + assertThat(article.getComments()).hasSize(2); + assertComment(article.getComments().getLast(), newComment, newAuthor); + } + + @Nested + class Fail { + @Test + void when_adding_an_existing_comment() throws CommentAlreadyExistException { + final var article = anArticle().build(); + article.addComment(ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR); + + assertThatThrownBy(() -> + article.addComment(ArticleBuilder.COMMENT_TEXT, ArticleBuilder.AUTHOR)) + .isInstanceOf(CommentAlreadyExistException.class); + } + } + + private static void assertComment(Comment comment, String commentText, String author) { + assertThat(comment.text()).isEqualTo(commentText); + assertThat(comment.author()).isEqualTo(author); + assertThat(comment.creationDate()).isBeforeOrEqualTo(LocalDate.now()); + } +} diff --git a/solution/pom.xml b/solution/pom.xml index 78878511..8eb55617 100644 --- a/solution/pom.xml +++ b/solution/pom.xml @@ -21,6 +21,7 @@ day10 day11 day12 + day13