Java 17 (applicable to Java 8 and later)
Table of Contents
- 2.1 TDD Definition
- 2.2 Why use TDD?
- 2.3 TDD Disadvantages and Criticisms
- 2.4 Prerequisites
- 2.5 Course plan
- 3.1 Introduction — Red-Green-Refactor
- 3.2 Analyze requirements
- 3.3 Write the first red test (Red)
- 3.4 Make the test green
- 3.5 Refactoring phase
- 3.6 Second cycle RGR
- 3.7 Introduction to ZOMBIES
- 3.8 Test Invalid Inputs
- 3.9 One last RGR cycle
- 3.10 Triangulation
- 3.11 Additional challenges
- 3.12 Module Summary
- 4.1 Introduction
- 4.2 Requirement Analysis — Portfolio
- 4.3 First test — Empty portfolio
- 4.4 Adding stock
- 4.5 Adding multiple stocks
- 4.6 Back to square one — Design review
- 4.7 First tests with the new design
- 4.8 Controversial decisions to move forward
- 4.9 Test multiple positions
- 4.10 Test total value
- 4.11 Additional challenges
- 4.12 Module Summary
- 5.1 Introduction
- 5.2 Where is TDD difficult to apply?
- 5.3 TDD is not a silver bullet
- 5.4 TDD and mocking
- 5.5 When the TDD loses its value — Anti-patterns
- 5.6 Course summary
- 7.1 Stock.java (first attempt)
- 7.2 Portfolio.java (first attempt)
- 7.3 PortfolioTestDrive.java
- 7.4 PortfolioTest.java (tests)
- 8.1 Stock.java (Java record)
- 8.2 Position.java
- 8.3 Portfolio.java (final)
- 8.4 PortfolioPositionTest.java (tests)
- 8.5 PositionsTestDrive.java
1. Course presentation
Test-Driven Development (TDD) is a proven technique for building robust software. The fundamental principle is simple: before implementing a feature, you write a test for it. This discipline forces the code to be cleaner, naturally testable and well-structured.
This course takes a hands-on approach: lots of code, little abstract theory. The main themes covered are:
- The advantages of TDD over writing tests after implementation
- How to use TDD to guide both implementation and code design
- The important nuances around TDD, as it is not a universal solution to any problem
Prerequisites: At least one year of Java experience and a good command of the fundamentals of test automation.
2. What is TDD and why use it?
Total module duration: 14m 9s
2.1 Definition of TDD
Writing good software is difficult. There are two main categories of errors when writing code:
- Compilation errors — The compiler helps a lot: wrong type assignment, syntax problems, etc.
- Runtime errors — There are an infinite number of ways to introduce a bug that compiles perfectly well (eg: an infinite loop).
TDD specifically helps address this second category.
The reference definition is that of James Shore and Shane Warden in their book The Art of Agile Development:
“TDD helps produce well-designed, well-tested, and well-factored code in small, verifiable steps.”
Let’s break down each term:
- Well-tested: obvious benefit — it’s in the TDD name itself.
- Well-designed: Testing forces code to be designed to be testable, which naturally leads to better design.
- Well-factored: short refactoring cycles continually improve code quality.
- Small, verifiable steps: Each step is tiny and can be immediately verified.
The basic TDD cycle is the Red-Green-Refactor (RGR):
RED → Écrire un test qui échoue (y compris un échec de compilation)
GREEN → Écrire l'implémentation minimale pour faire passer le test
REFACTOR → Améliorer le code sans casser les tests
TDD is not just “write unit tests first”. It also includes:
- Let testing drive design
- Taking small steps at a time
- Rerun tests frequently during development
- Ending up with a robust long-term test suite
2.2 Why use TDD?
TDD brings benefits at several levels: individual, team and organization.
For the individual:
- Fear Reduction: With a solid test suite, modifying existing code is less risky. The fear of breaking something is minimized, and tasks are completed more quickly.
- Less debugging: If unit tests indicate exactly what is wrong, the time spent debugging decreases drastically. Some call TDD the opposite of DDD (Debugger-Driven Development), where you change something, break something else, debug, fix, and the cycle starts again indefinitely.
- Living Documentation: Well-written tests act as executable documentation. They literally say how each component is supposed to behave. With a little practice, you can read the tests to understand the functionality of software.
For the team:
- Senior members spend less time explaining how the code works because the documentation isn’t in their heads or on an outdated Wiki — it’s in the test code that anyone can run.
- Less time in code review (code review), because a solid test suite prevents many logical errors.
- Overall productivity remains constant rather than slowing down with project growth.
For organization:
- Overall reduction in the number of bugs and time spent fixing them in the long term.
2.3 Disadvantages and criticisms of TDD
Here are the most frequent criticisms, with their counter-arguments:
Criticism 1: “TDD has a learning curve and slows down.”
Counterargument: Learning to use an IDE takes time, learning Git takes time — but no one questions the long-term benefits of these tools. TDD is an investment.
Criticism 2: “It’s time consuming. I would have finished the task twice as fast without it.”
Counter-argument: This may be true for the first few months, but we do not count the time spent debugging, correcting accidentally introduced bugs, or rethinking a poorly thought-out design. The time spent on bugs that would not have existed thanks to TDD is invisible but very real. Not counting the time of the tester who found the bug, the project manager who prioritized it, etc. Research has been conducted on this subject and confirms that TDD massively reduces the number of bugs in the long term.
Critical 3: “Testing becomes a huge maintenance cost.”
Counterargument: Yes, if the tests are poorly written. Well-structured tests are easy to maintain. And testing is related to behaviors (interfaces), not implementation details. If you change the internal implementation without changing the behavior, the tests should not break. If your tests constantly break during internal refactorings, it’s a sign that you’re testing implementation details rather than behavior.
Criticism 4: “We waste time writing tests for code that will never change.”
Counterargument: We can never be sure that a code will not change. And for code that effectively doesn’t change, testing costs next to nothing to maintain.
2.4 Prerequisites
To take this course while coding, you must be comfortable with:
- Creating a blank Java project
- Adding Maven dependencies and running code
- The environment used in the course: Windows, IntelliJ IDEA, JUnit 5, Maven
To add JUnit 5 via Maven, go to mvnrepository.com and search for:
junit-jupiter-api(JUnit 5)junit-jupiter-params(for parameterized tests)
2.5 Course outline
The course is structured as follows:
| Module | Content |
|---|---|
| 2 | TDD Definition, Advantages, Disadvantages, Prerequisites |
| 3 | Red-Green-Refactor on a simple example (string truncation method) |
| 4 | TDD applied to a multi-class mini-project (Stock portfolio) |
| 5 | Nuances: where TDD is difficult, mocking, anti-patterns |
3. Start small with TDD
Total module duration: 32m 57s
3.1 Introduction — Red-Green-Refactor
TDD consists of moving forward by small, simple but assured steps. This module’s example is deliberately simple on the surface, but it will turn out to be more complex than it appears — which is a great example of how TDD helps anticipate bugs.
The Red-Green-Refactor cycle takes place as follows:
1. RED → Écrire un test qui échoue.
Un échec de compilation = un test rouge.
2. GREEN → Écrire l'implémentation minimale pour faire passer le test.
3. REFACTOR → Améliorer le code (production ET tests).
Relancer les tests pour s'assurer qu'ils sont encore verts.
4. Répéter le cycle.
3.2 Analyze requirements
The requirement: Truncate a string with an ellipsis (...) if it exceeds a certain character limit, and the limit must be flexible.
Before writing the first test, you must think. The RGR diagram is incomplete without a prior step: Think.
THINK → RED → GREEN → REFACTOR → THINK → RED → ...
Analysis of requirement keywords:
- Any string must be processed as input
- Limit must be flexible
Identified variables:
- The string to truncate
- The flexible limit
Initial tests imagined in natural language:
- When limit is reached → truncate
- When the limit is not reached → do not change
3.3 Write the first red test (Red)
A good test name should be descriptive and clearly indicate what is being tested and why it would fail.
// Test initial (pseudocode)
assertEquals("The economy...", StringUtil.truncate("The economy is about to", 11));
At this point, StringUtil does not exist. IntelliJ highlights it red. If we try to execute, the test fails because the code does not compile. This is the end of the red phase.
Benefit of the red phase: Instead of worrying about implementation, we focus on creating a simple, readable and testable interface. The method name and its two parameters are the result of a design decision — that’s exactly what the red phase is for.
3.4 Make the test green (Green)
Two strategies to quickly turn a test green:
- Fake it — Return a constant or hardcoded value that matches the expected value in the test.
- Obvious implementation — Write the real implementation directly if it is simple enough.
We start with fake it:
public class StringUtil {
public static String truncate(String input, int limit) {
return "The economy..."; // valeur codée en dur - FAKE
}
}
The test passes. The green phase is over.
3.5 Refactoring phase
Now it’s time to replace the hardcoded value with a real implementation:
public static String truncate(String input, int limit) {
return input.substring(0, limit) + "...";
}
The test still passes. The refactoring phase is complete.
Why bother with TDD if the implementation is so simple? → Let’s continue and see.
3.6 Second RGR cycle
New test (in natural language): “If the limit is not reached, the string should not change.”
// Deuxième test
assertEquals("The economy is about to", StringUtil.truncate("The economy is about to", 40));
We restart all the tests (not just the new one). Result: exception!
StringIndexOutOfBoundsException: Range [0, 40) out of bounds for length 23
The Javadoc for String.substring() specifies: if the ending index is greater than the length of the string, an exception is thrown. Our first implementation had a hidden bug — the second test just revealed it.
Correction:
public static String truncate(String input, int limit) {
if (input.length() < limit) { // BUG: mauvais opérateur de comparaison
return input;
}
return input.substring(0, limit) + "...";
}
We run the tests again — both fail now! A moment of panic, then we realize: the wrong comparison operator < instead of >. The first test is telling us that we have made things worse.
Important lesson: When we have several tests, the existing tests alert us immediately when we introduce a regression. It is invaluable.
Final correction:
public static String truncate(String input, int limit) {
if (input.length() <= limit) {
return input;
}
return input.substring(0, limit) + "...";
}
Both tests pass. Second RGR cycle completed.
3.7 Introduction to ZOMBIES
When you don’t know what to test anymore, ZOMBIES comes to the rescue. This mnemonic is attributed to James Grenning:
| Letter | Meaning |
|---|---|
| Z | Zero (zero case) |
| O | One (case one) |
| M | Many (multiple cases) |
| B | Boundary behavior |
| I | Interface definition |
| E | Exceptional behavior |
| S | Simple scenarios — simple solutions |
Application to the truncation test — Boundary Behavior:
Professional testers recognize many types of boundaries (component boundaries, system boundaries, etc.), but the simplest form applies to basic types (strings, numbers). If we truncate a string, we should test at the edges of its length, not just an arbitrary index inside or outside.
The string "The economy is about to" is 23 characters long. Border test:
// À la limite exacte : la chaîne ne doit pas changer
assertEquals("The economy is about to", StringUtil.truncate("The economy is about to", 23));
We run the test — it fails! Yet we thought we had covered this case…
The bug: our condition is input.length() <= limit, which means that if length == limit, we return input — which is correct. But wait… "The economy is about to".length() is 23, and the limit is 23. 23 <= 23 is true, so we return the input. The test should pass…
Rereading carefully: the condition <= uses an exclusive index for the limit. If the limit is inclusive (limit 23 means that the first 23 characters are allowed), then for length == limit, we must not truncate. But our substring(0, limit) would still return the first 23 characters. Upon reflection, the bug lies elsewhere: the condition <= should be < in this context.
Important point on inclusive/exclusive limits: It is often an ambiguity with the dates (eg: a promotion valid “until the 20th of the month” — does it stop at the end of the day of the 20th or at the beginning of the 20th?). Always check with the trade.
3.8 Test invalid inputs
ZOM — Zero, One, Many: For a string, the three categories of entries are 0 characters, 1 character, and many. For the limit integer, how to handle a value of 0 or negative?
Scenarios identified:
- Input
null→ throw exception - Limit 0 or less → throw exception (zero or negative limit is meaningless)
Two possible approaches for invalid inputs: return the input unchanged or throw an exception. This course chooses to throw an IllegalArgumentException.
Test for null entry:
assertThrows(IllegalArgumentException.class,
() -> StringUtil.truncate(null, 5));
JUnit provides assertThrows — which makes the case for testing for runtime exceptions.
Implementation:
public static String truncate(String input, int limit) {
if (input == null) {
throw new IllegalArgumentException("String input must not be null");
}
if (limit < 1) {
throw new IllegalArgumentException("Limit input must be greater than 0");
}
// ... reste de l'implémentation
}
Note on
NullPointerExceptionvsIllegalArgumentException: The Java convention (seeObjects.requireNonNull) is to throw aNullPointerExceptionto rejectnull. But if you see anNPEat runtime, you have to guess if a method was called onnullor if it’s a guard clause. To avoid this ambiguity, the author prefersIllegalArgumentException. Both approaches are valid; It’s a matter of team preference.
3.9 One last RGR cycle
Combinatorics reveals another limiting case: what happens if the entry is shorter than the ellipse we are supposed to add?
- If the entry is 3 characters long and we add
"..."(3 characters), we double the length. - The
truncatemethod would lengthen the original string — the opposite of what it is supposed to do!
The business confirms: in this case, return the entry unchanged.
// Entrée courte avec limite encore plus courte
assertEquals("The", StringUtil.truncate("The", 2));
The test fails. We need a new guard clause:
String ellipsis = "...";
if (input.length() <= limit || input.length() <= ellipsis.length()) {
return input;
}
With a test parameterized to cover the two cases at the edges of the length of the ellipse (2 and 3):
@ParameterizedTest
@MethodSource("shortInputLessOrEqualToEllipsis")
void inputShorterOrEqualThanLimit_StringIsNotChanged(String input, int limit) {
Assertions.assertEquals(input, StringUtil.truncate(input, limit));
}
static Stream<Arguments> shortInputLessOrEqualToEllipsis() {
return Stream.of(
Arguments.of("The", 2),
Arguments.of("The", 3)
);
}
All tests pass.
3.10 Triangulation
Triangulation is the technique that we practiced throughout this module without explicitly naming it.
“As your tests become more and more specific, your implementation becomes more and more generic.”
- We start with a first concrete and simple test → we can write a simplistic (fake) implementation.
- We add a second concrete test → the implementation must become more general to satisfy both.
- We continue to add assertions → the implementation converges to the true generic solution.
These checks can take the form of separate tests or a single parameterized test.
Final refactoring — private method extraction:
After merging the two guard clauses with a || operator, the code becomes difficult to read:
if (input.length() <= limit || input.length() <= ellipsis.length()) {
return input;
}
We extract a private method with a descriptive name:
private static boolean inputTooShort(String input, int limit, String ellipsis) {
return input.length() <= limit ||
input.length() <= ellipsis.length();
}
Which gives:
if (inputTooShort(input, limit, ellipsis)) {
return input;
}
Principle: small step → test → small step → test.
3.11 Additional Challenges
Anti-pattern identified: The test at the edges of the length of the ellipse (values 2 and 3) couples the tests to an internal implementation detail (the length of "..."). The ellipse is inside the method — it’s a black box invisible from the outside.
If the business decides to change the ellipse for something else (eg: "→→" or a string of unpredictable length), it will be necessary to change both the implementation and the tests.
Proposed solution: Override the truncate method to accept a third argument — break characters:
public static String truncate(String input, int limit, String cutOffChars) {
// implémentation avec cutOffChars variable
}
// La méthode à 2 arguments délègue à la méthode à 3 arguments :
public static String truncate(String input, int limit) {
return truncate(input, limit, "...");
}
Thus, the tests of the 2 argument method exercise the 3 argument method, and the edge tests are no longer coupled to an internal value.
Other challenges left as exercise:
- Test empty strings (length 0) and
blankstrings (containing only white spaces) - Empty strings have length 0;
blankcontains only whitespace characters
3.12 Module summary
We went from a single line solution full of holes to a robust and well tested implementation. And even at the end of the module, there was still work (the overloaded method).
That’s what TDD is: discovering and addressing problems early, one small step at a time, rather than debugging and fixing bugs.
Summary of learning:
- Advantages and criticisms of TDD
- Multiple Red-Green-Refactor cycles in practice
- The Think phase before writing the first test
- Write a red test first; return a fake value to quickly turn green; refactor to a real implementation
- Refactor production code AND tests**
- Triangulation: the more specific the tests, the more generic the implementation becomes
- ZOMBIES as a mnemonic to identify new tests
- Other useful mnemonics: FIRST, BICEP, CORRECT (see the Fundamentals of Test Automation in Java course on Pluralsight)
4. Use TDD to guide design
Total module duration: 41m 29s
4.1 Introduction
In the previous module, we implemented a single method — TDD mainly helped produce a more robust implementation, with few design decisions (except the realization that we might want a 3-argument method).
In this module, we will see how to create several coherent classes and assemble them to form a mini-application, with the TDD which guides the design decisions.
4.2 Requirements analysis — Portfolio
The Requirement: Create a stock portfolio that can accept and display multiple positions in various stocks, and that can display:
- The quantity held
- The average purchase price
- The total value of a position
- The total value of the portfolio
Business context:
- We buy a certain number of shares (shares) at a given price.
- One can buy shares of the same stock at different times and at different prices.
- The total value of a position = quantity × price
- The average price (average price or break-even price) is more complex when the quantities differ:
$$\text{Average price} = \frac{\sum_{i}(\text{qty}_i \times \text{px}i)}{\sum{i}\text{qty}_i}$$
Example: 1 share at 100 + 1 share at 80 → average price = 90.
Entities identified:
Portfolio(the portfolio)Stock(the stock with its public data)
Initial tests (in natural language, with ZOMBIES — ZOM):
| Scenario | Test |
|---|---|
| 0 stock | Portfolio with zero stock → total value = 0 |
| 1 stock | A stock → displays the correct total value |
| 2 different stocks | Two stocks → shows correct total value |
Tabular format of test data:
| Stock | Qty | Px | Value |
|---|---|---|---|
| (empty) | 0 | 0 | 0 |
| MSFT | 10 | 260 | 2600 |
| MSFT | 10 | 260 | 2600 |
| AAPL | 2 | 150 | 300 |
| Total | 2900 |
4.3 First test — Empty portfolio
Bottom-up approach: First write the assertion with classes and methods that do not yet exist — this allows you to fully concentrate on the design.
@Test
void emptyPortfolio_zeroPositions() {
var portfolio = new Portfolio();
Assertions.assertEquals(0, portfolio.getAllPositions().size());
}
Note on
var: In modern Java, one can usevarto shorten code when the type is clear from the initialization line.
The test does not compile → red phase. We create the class and the method:
public double getTotalValue() {
return 0; // dummy value
}
The test passes. Refactoring phase: we introduce a totalValue class field initialized to 0 by default.
4.4 Adding stock
@Test
void portfolioWithOneStock_calculatesTotalValue() {
int qty = 10;
double px = 260;
double value = qty * px;
var portfolio = new Portfolio();
portfolio.add(new Stock("MSFT", qty, px));
Assertions.assertEquals(value, portfolio.totalValue());
}
Note on
doubleprimitives: Mathematical operations ondoubleprimitives can result in loss of precision. This is noted on TODO’s list for later correction with a Money API.
To implement quickly, we ensure that getTotalValue() returns the value of only the stock added. This breaks the first test which uses the no-argument constructor. You must explicitly add the constructor without arguments (Java no longer generates it automatically when you define a constructor with arguments).
Adding step by step:
- Add a constructor that accepts a
Stock - Re-add the constructor without arguments explicitly
- Implement
getTotalValue()to return the value of only stock - Add fields and getters in
Stock - Add the
totalValue()method inStock
Both tests pass. However, we introduced code that can manage a single stock — which will change with the next test.
4.5 Added multiple stocks
@Test
void portfolioWithMultipleStocks_calculatesTotalValue() {
var portfolio = new Portfolio();
portfolio.add(new Stock("MSFT", 10, 260));
portfolio.add(new Stock("AAPL", 2, 150));
Assertions.assertEquals(2600 + 300, portfolio.totalValue());
}
The test fails. The simplest solution for holding a collection of elements: a List.
List<Stock> stocks = new ArrayList<>();
public void add(Stock stock) {
stocks.add(stock);
}
public double getTotalValue() {
return stocks.stream()
.mapToDouble(Stock::totalValue)
.sum();
}
All tests pass. We then add a test for the same stock added twice with different quantities and prices:
@Test
void portfolioWithAddedStockAtDifferentPrice_calculatesTotalValue() {
var portfolio = new Portfolio();
portfolio.add(new Stock("AAPL", 1, 150));
portfolio.add(new Stock("AAPL", 2, 100));
Assertions.assertEquals(150 + 200, portfolio.totalValue());
}
This test is green immediately — the existing implementation covers this scenario.
Test drive: We create a PortfolioTestDrive class with a main method to visualize the result:
{ AAPL | Qty: 1 | Px: 150.0 | Value: 150.0 }
{ AAPL | Qty: 2 | Px: 100.0 | Value: 200.0 }
=================================
350.0
Issue discovered: The same stock appears twice in the list. The business query was to merge entries from the same stock to display a single row with the total quantity and average price. Our current design does not support this.
4.6 Back to square one — Design review
Author’s confession: He implemented this design using TDD, and after reaching this point (stock list), he realized it was not a good design. So he returned about an hour of work and started again with a better design.
“Most TDD tutorials present a smooth, seamless progression — which gives the false impression that TDD is an easy practice. This is not the case. This example shows what actually happens in real life.”
Thinking about entities:
The Stock entity represents public data:
- List price – Historical Price Data
- Market capitalization (market cap)
- Miscellaneous financial ratios
But the outside world doesn’t care about our trading activity. Only the investor cares about the purchase price and quantity.
Question: Should we mix public and private data in a single entity? Probably not.
And the requirements mention an interesting term: position. When we buy something at a particular price, we enter a position. An entity is missing!
| Entity | Responsibility |
|---|---|
Stock | Public security data (symbol, market price, etc.) |
Position | Private investor data (quantity purchased, purchase price) |
Portfolio | All investor positions |
4.7 First tests with the new design
Phase Think — new entities identified, new natural language tests:
- No stock → no position → empty display
- One position → shows correct data
- Two different positions → shows correct data
- Same stock added twice → single position (quantities merged, price averaged)
New first test:
@Test
void emptyPortfolio_zeroPositions() {
var portfolio = new Portfolio();
Assertions.assertEquals(0, portfolio.getAllPositions().size());
}
We create Portfolio with getAllPositions() returning a List<Position>, and we create the Position entity. The test compiles → green.
Refactoring: We introduce a positions field in Portfolio, initialized, and return it.
Test for a position:
@Test
void portfolioWithOnePosition_ReturnsThatPosition() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 10, 260));
Assertions.assertEquals(1, portfolio.getAllPositions().size());
// ...
}
We implement the add(Position) method. We add a constructor to Position. We create the Stock entity.
Using Java Records for Stock:
public record Stock(String symbol) {
}
Java Records (since Java 16): With the
recordsyntax, Java automatically generates all boilerplates: final fields, constructor (all fields),hashCode(),equals(), andtoString()— using the declared fields. This is ideal for purely data carriers without business logic.
4.8 Controversial decisions to move forward
Question raised in the tests: Is one assertion required per test?
Unit testing purists will say: only one assertion per test to have a single point of failure. The vast majority of unit tests should only have one assertion.
But the author chooses here to have several assertions in the same test:
@Test
void portfolioWithOnePosition_ReturnsThatPosition() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 10, 260));
Assertions.assertEquals(1, portfolio.getAllPositions().size());
Assertions.assertEquals(10, portfolio.getPosition("MSFT").getQty());
Assertions.assertEquals(260, portfolio.getPosition("MSFT").getAveragePx());
Assertions.assertEquals(2600, portfolio.getPosition("MSFT").getValue());
}
Rationale:
- The
getValue()method returns the result of a mathematical operation — there is logic, so we should test it. - The author knows that the implementation will evolve to merge quantities and average prices — he wants to make sure that nothing breaks during these changes.
- He notes on his TODO list: “Consider refactoring these tests once all functionality is implemented.”
Static factory method to simplify testing:
static Position position(String symbol, int qty, double px) {
return new Position(new Stock(symbol), qty, px);
}
Two benefits:
- Tests are shorter and more readable
- Maintainability: if we add a parameter to the constructor, we only change this place
“Many say they don’t like tests because they are laborious to maintain. This is not the case if you refactor them correctly.”
4.9 Test multiple positions
@Test
void portfolioWithTwoDifferentPositions_ReturnsThosePositions() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 10, 260));
portfolio.add(position("AAPL", 2, 150));
Assertions.assertEquals(2, portfolio.getAllPositions().size());
// assertions sur chaque position...
}
This test is green immediately. To ensure that it can also fail, we temporarily modify an expected value and verify that the test fails for the right reason.
Test for the same stock twice (merge phase):
@Test
void portfolioWithSameStock_ReturnsOnePosition() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 10, 260));
portfolio.add(position("MSFT", 5, 200));
Assertions.assertEquals(1, portfolio.getAllPositions().size());
}
The test is green immediately! For what ? Because the implementation is now based on a HashMap with the stock symbol as the key — a HashMap does not accept duplicate keys.
But the following test reveals a problem:
@Test
void portfolioWithSameStock_ReturnsCorrectQty() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 10, 260));
portfolio.add(position("MSFT", 1, 200));
Assertions.assertEquals(11, portfolio.getPosition("MSFT").getQty());
}
This test fails — the second position simply overwrites the first. Merging must be implemented.
Implementation in Portfolio.add():
public void add(Position position) {
String symbol = position.getStock().symbol();
if (positions.containsKey(symbol)) {
Position existing = positions.get(symbol);
int newQty = existing.getQty() + position.getQty();
double newAvgPx = (existing.getQty() * existing.getAveragePx()
+ position.getQty() * position.getAveragePx()) / newQty;
existing.setQuantity(newQty);
existing.setAveragePx(newAvgPx);
} else {
positions.put(symbol, position);
}
}
Tests for quantity, average price and value:
@Test
void portfolioWithSameStock_ReturnsCorrectAveragePrice() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 1, 240));
portfolio.add(position("MSFT", 1, 220));
Assertions.assertEquals(230, portfolio.getPosition("MSFT").getAveragePx());
}
@Test
void portfolioWithSameStock_ReturnsCorrectPositionValue() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 2, 240));
portfolio.add(position("MSFT", 1, 220));
double expected = 2 * 240 + 220;
Assertions.assertEquals(expected, portfolio.getPosition("MSFT").getValue());
}
4.10 Test total value
@Test
void complexPortfolio_ReturnsCorrectTotalValue() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 1, 260));
portfolio.add(position("MSFT", 2, 250));
portfolio.add(position("AAPL", 5, 90));
portfolio.add(position("AAPL", 10, 80));
portfolio.add(position("ORCL", 100, 80));
Assertions.assertEquals(3, portfolio.getAllPositions().size());
Assertions.assertEquals(10010, portfolio.getTotalValue());
}
Expected calculation:
- MSFT: (1×260 + 2×250) = 760, qty = 3, average price = 253.33…, value = 760
- AAPL: (5×90 + 10×80) = 1250, qty = 15, average price = 83.33…, value = 1250
- ORCL: 100×80 = 8000
Total = 760 + 1250 + 8000 = 10010
Implementation of getTotalValue():
public double getTotalValue() {
return positions.values().stream()
.mapToDouble(Position::getValue)
.sum();
}
Final test drive with PositionsTestDrive:
{ MSFT | Qty: 3 | Px: 253.33 | Value: 760.0 }
{ AAPL | Qty: 15 | Px: 83.33 | Value: 1250.0 }
{ ORCL | Qty: 100 | Px: 80.0 | Value: 8000.0 }
===================================
10010.0
Each line is now unique with the correct total quantity and value.
Note: The
print()method does not need to be tested because it just uses other methods already covered by tests.
4.11 Additional Challenges
There is still work to improve:
-
Encapsulate position counting: The
getAllPositions()method exposes the underlying data structure (theMap). If we change the implementation, the calling code could break. Challenge: implement agetPositionCount()method and hide the internalMap. -
Parameterized test for the number of positions: The tests verifying the correct number of positions are scattered in several methods. Challenge: create a single parameterized test
portfolioReturnsCorrectPositionCount. -
Tests for invalid values (ZOMBIES — E): No tests yet for: zero position, zero stock, empty symbol, negative numbers, etc.
-
Removemethod: If you can buy, you can also sell. Implement aremovemethod that reduces a position and recalculates the new quantity and new average price. -
Replacing
doublewith a Money API: Arithmetic operations on primitivedoublemay result in rounding errors. A real financial application must use a Money API. The notion of currency is also very important. See JavaMoney on GitHub, which implements the official specification JSR-354 (Java Specification for Money).
4.12 Module summary
This module demonstrated a different approach:
- Bottom-up approach: Write the assertion with the desired API, reassemble the test, then write the implementation.
- TDD guided the design to a certain point, then we got stuck — this is normal in practice.
- Sometimes the small steps weren’t so small — we had to go back and start again with an improved design.
- We didn’t write perfect test or production code from the start — we put things on the TODO list to address later.
What this module showed:
- TDD is useful for driving a more robust implementation of a single method
- TDD is also useful for driving a clean and testable design when several classes are involved
- All this with a reliable test suite that can be rerun frequently to detect regressions
5. Nuances and best practices of TDD
Total module duration: 20m 47s
5.1 Introduction
This final module covers several important concepts for the TDD journey:
- TDD is not always easy to apply — There are situations where it is difficult.
- TDD is not a silver bullet — It does not represent all testing activities, or even the majority.
- TDD and mocking — A lot of code interacts with the outside world (files, APIs, databases). Testing this code often involves mocking.
- Test Anti-Patterns — Patterns that reduce the TDD value and how to avoid them.
5.2 Where is TDD difficult to apply?
“Impossible” is too big a word. “Very difficult” is more accurate.
Legacy systems:
The term “legacy system” is loaded with meaning. Different definitions:
- A system written decades ago in an obsolete language
- Unfamiliar legacy software from another team
Michael Feathers in Working Effectively with Legacy Code gives a simple definition:
“Any code or software that is not covered by tests.”
The argument: Unless you are the first person to commit code to a brand new project, you are modifying existing code most of the time. Modify = risk breaking something. Without tests to alert you, you fear changing the code, and you also fear cleaning and refactoring it.
The chicken and egg problem in legacy:
“The challenge of legacy code is that because it was created without tests, it usually isn’t designed for testability. To introduce tests, you need to change the code. To change the code with confidence, you need to introduce tests.”
There is no one-step solution. Michael Feathers offers a variety of guiding principles and techniques—an entire book is devoted to them.
Other difficult contexts:
- User interface (UI) code: It is technically possible to apply TDD to the UI, but it is complex and much more difficult than with business logic.
- Integration code: Code that interacts strongly with external systems (third-party APIs, databases, file systems). Without adequate mocking, tests are slow and fragile.
- Complex algorithms: Some algorithms require a lot of prior thinking. TDD can slow down if we don’t yet understand the direction to take.
5.3 TDD is not a silver bullet
The TDD definition uses the word “helps”. This means that TDD does not guarantee:
- A perfect code
- Bug-free software
- A solution to all development problems
What TDD does not do:
-
Not a replacement for good clean code practices: You can still end up with huge and bloated classes, methods with misleading names, too long, with too many parameters, local variables with terrible names. TDD can nudge you towards better decisions, but doesn’t force you to.
-
Not a replacement for all testing activities: In addition to unit testing, there are higher level tests (integration tests, UI tests) that have their place. It is true that TDD can be applied to certain higher-level tests — we then speak of ATDD (Acceptance Test-Driven Development). But the testing pyramid remains far from representing all possible testing activities.
The testing pyramid:
/\
/ \
/ UI \
/------\
/ Integ. \
/------------\
/ Unit Tests \
/________________\
Unit tests are the basis — numerous and fast. The higher you go, the rarer, slower and more expensive the tests become.
TDD mainly applies to the base of the pyramid (unit tests). The higher layers (integration, UI) require other approaches.
5.4 TDD and mocking
This course focused on pure TDD practice — all code was in memory. In reality, a lot of code touches:
- The file system
- HTTP APIs
- Databases
- Other external systems
This complicates testing because it requires additional knowledge to test correctly.
Two approaches to testing code that interacts with the outside world:
Approach 1: Mocks, stubs, spies, dummies, fakes
These terms all refer to slightly different variations of substitute objects (test doubles) that replace actual APIs and databases.
| Term | Description |
|---|---|
| Mock | Substitute object that verifies that certain methods have been called |
| Stub | Substitute object that returns predefined values |
| Spy | Wraps the actual object and records interactions |
| Dummy | Object passed but never used in the test |
| Fake | Simplified but functional implementation (e.g. in-memory database) |
It takes time to understand the differences and practice to apply them correctly. This also involves mocking frameworks. The author recommends:
- Mockito (most popular)
- EasyMock
Approach 2: True dependencies
Use real dependencies — real files, real APIs, real databases. Not necessarily a full production copy, but mini copies with just enough test data.
Advantages: Closer to real use → more value.
Major disadvantage: These tests are much slower (seconds, not milliseconds). They are more fragile and depend on the environment.
Concrete example: The Apache Commons Lang library (string manipulation). Most code and tests do not use external dependencies. But for some specific system-related features (character encoding, etc.), tests that use real system dependencies are significantly slower.
5.5 When TDD loses its value — Anti-patterns
We sometimes hear criticism that TDD is an expensive practice to maintain. Two important points:
-
Tests cost anyway, whether they are written before or after production code. This criticism is actually aimed at test automation in general, not TDD specifically.
-
The feeling of being overwhelmed by testing mainly comes from bad testing code practices.
Just as production code has its anti-patterns (code smells), test code has its own anti-patterns.
Recalled production code anti-patterns:
- Variables and methods with wrong names
- Methods with too many parameters
- Huge methods (50, 100 or 500 lines)
Anti-patterns specific to test code:
Anti-pattern #1: The test without a descriptive name (Poor test name)
A test without a clear name — it’s not immediately clear what it’s supposed to test.
// Mauvais
@Test
void test1() { ... }
// Bon
@Test
void limitReached_stringTruncates() { ... }
Anti-pattern #2: The catch-all test (Clueless test)
A test that checks or asserts several distinct things — several units of behavior in a single test.
// MAUVAIS - teste plusieurs comportements métier différents
@Test
void testPortfolio() {
// teste le portfolio vide
assertEquals(0, portfolio.getTotalValue());
// teste l'ajout d'un stock
portfolio.add(stock);
assertEquals(2600, portfolio.getTotalValue());
// teste la fusion des positions
portfolio.add(sameStock);
assertEquals(1, portfolio.getAllPositions().size());
}
Each assertion is a potential point of failure. The vast majority of tests should have a single assertion to keep it simple.
Recognized exceptions: Checking multiple simple getters on the same entity may be acceptable (see module 4). But testing different parts of business logic in a single test is definitely not.
Anti-pattern #3: Tests coupled with implementation
Tests that check internal implementation details rather than externally observable behavior.
Example from module 3: test at the edges of the length of the ellipse with hardcoded values 2 and 3 — these values are coupled to the length of "..." which is an internal detail of the method.
If the business changes the ellipse, both the implementation and the tests must be changed. Tests should test the behavior and the interface, not the internals.
Rule: Everything inside a method is a black box. Tests should not know these details.
Anti-pattern #4: Slow tests
Tests that take seconds (network calls, databases, files) mixed with fast unit tests. If the test suite becomes slow, developers stop running it frequently — which contradicts the fundamental principle of TDD.
Solution: Separate tests into layers (fast unit tests vs. slow integration tests), and ensure that unit tests remain millisecond-level.
Anti-pattern #5: Non-deterministic tests (Flaky tests)
Tests that sometimes pass and other times fail without code changes. Common causes: system time dependencies, test execution order, shared resources.
Rule: Each test must be isolated, repeatable and deterministic.
Anti-pattern #6: Tests without assertion (No assertion test)
A test that runs without ever making an assertion — it always passes, but doesn’t check anything.
// MAUVAIS - aucune assertion
@Test
void testSomething() {
var result = someMethod();
// ...on a oublié d'asserter quelque chose
}
Indicators that we are on the right track:
- We code in small steps — rarely more than 10-20 lines before rerunning the tests
- We constantly make programming errors, but we find them in a few minutes and can easily correct them
- We refactor the production code and the tests
- We have complete confidence that the tests will catch errors and only fail for the right reasons — because we have seen them and pass and fail
- We spend little time debugging
5.6 Course Summary
Recommended courses to continue learning:
| Courses | Platform |
|---|---|
| Fundamentals of Test Automation in Java | Pluralsight |
| Java Mocking with Mockito | Pluralsight |
| Java Mocking with EasyMock | Pluralsight |
| TDD — The Benefits (research module) | Pluralsight |
| Java 17 Best Practices | Pluralsight |
| Java Refactoring: Best Practices | Pluralsight |
6. Source code — Module 3: StringUtil
6.1 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.tdd-course</groupId>
<artifactId>Java-17-TDD</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
6.2 StringUtil.java (production)
Path: src/main/java/com/tdd/m3/StringUtil.java
package com.tdd.m3;
public class StringUtil {
public static String truncate(String input, int limit) {
if (input == null) {
throw new IllegalArgumentException("String input must not be null");
}
if (limit < 1) {
throw new IllegalArgumentException("Limit input must be greater than 0");
}
String ellipsis = "...";
if (inputTooShort(input, limit, ellipsis)) {
return input;
}
return input.substring(0, limit) + "...";
}
private static boolean inputTooShort(String input, int limit, String ellipsis) {
return input.length() <= limit ||
input.length() <= ellipsis.length();
}
// TODO - Exercise
public static String truncate(String input, int limit, String cutOffChars) {
return "";
}
}
Final implementation notes:
- Guard clause for
null: throwsIllegalArgumentExceptionwith a clear message. - Guard clause for
limit < 1: a limit of 0 or negative has no meaning. inputTooShort(): private method extracted for readability. It covers two cases:
input.length() <= limit: string is shorter than or equal to limit → no need to truncateinput.length() <= ellipsis.length(): the string is too short for adding the ellipsis to make sense
- TODO: The overloaded method
truncate(String, int, String)is left as an exercise.
6.3 StringUtilTest.java (tests)
Path: src/test/java/com/tdd/m3/StringUtilTest.java
package com.tdd.m3;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
public class StringUtilTest {
@ParameterizedTest
@MethodSource("validLimitProvider")
void limitReached_stringTruncates(int limit, String output) {
String input = "The economy is about to";
Assertions.assertEquals(output, StringUtil.truncate(input, limit));
}
static Stream<Arguments> validLimitProvider() {
return Stream.of(
Arguments.of(1, "T..."), // limite minimale possible
Arguments.of(11, "The economy...")
);
}
@ParameterizedTest
@MethodSource("inputOutputLimitProvider")
void limitNotReached_stringNotChanged(String input, int limit) {
Assertions.assertEquals(input, StringUtil.truncate(input, limit));
}
static Stream<Arguments> inputOutputLimitProvider() {
String input = "The economy is about to";
return Stream.of(
Arguments.of(input, 40),
Arguments.of(input, input.length()) // à la frontière : length == limit
);
}
@ParameterizedTest
@MethodSource("invalidArgumentProvider")
void invalidInput_isRejected(String input, int limit) {
Assertions.assertThrows(IllegalArgumentException.class,
() -> StringUtil.truncate(input, limit));
}
static Stream<Arguments> invalidArgumentProvider() {
return Stream.of(
Arguments.of(null, 5),
Arguments.of("Some input", 0)
);
}
@ParameterizedTest
@MethodSource("shortInputLessOrEqualToEllipsis")
void inputShorterOrEqualThanLimit_StringIsNotChanged(String input, int limit) {
Assertions.assertEquals(input, StringUtil.truncate(input, limit));
}
static Stream<Arguments> shortInputLessOrEqualToEllipsis() {
return Stream.of(
Arguments.of("The", 2),
Arguments.of("The", 3)
);
}
}
Testing Notes:
- Tests parameterized with
@MethodSource: each static*Provider()method returns aStream<Arguments>which feeds the test iterations. - Test
limitReached_stringTruncates: covers the minimum limit (1) and an intermediate limit (11) — triangulation. - Test
limitNotReached_stringNotChanged: covers a case well above length (40) and exactly at length (23) — boundary test. - Test
invalidInput_isRejected: checks thatnulland a limit of 0 throwIllegalArgumentException. - Test
inputShorterOrEqualThanLimit_StringIsNotChanged: covers the edges of the length of the ellipse (2 and 3) — potential anti-pattern reported in the course (coupling to internal details).
7. Source code — Module 4: Portfolio (first attempt)
7.1 Stock.java (first attempt)
Path: src/main/java/com/tdd/m4/firstattempt/Stock.java
package com.tdd.m4.firstattempt;
public class Stock {
String symbol;
int qty;
double px;
public Stock(String symbol, int qty, double px) {
this.symbol = symbol;
this.qty = qty;
this.px = px;
}
public String getSymbol() {
return symbol;
}
public int getQty() {
return qty;
}
public double getPx() {
return px;
}
public double totalValue() {
return qty * px;
}
@Override
public String toString() {
return String.format("{ %s | Qty: %s | Px: %s | Value: %s}",
symbol, qty, px, qty * px);
}
}
Design issue identified: This class mixes public data of the stock (symbol) with private data of the investor (quantity, purchase price). This will be the sticking point that will lead to the design revision.
7.2 Portfolio.java (first attempt)
Path: src/main/java/com/tdd/m4/firstattempt/Portfolio.java
package com.tdd.m4.firstattempt;
import java.util.ArrayList;
import java.util.List;
public class Portfolio {
List<Stock> stocks = new ArrayList<>();
public void printPortfolio() {
stocks.forEach(System.out::println);
}
public void add(Stock symbol) {
stocks.add(symbol);
}
public double totalValue() {
if (stocks == null) {
return 0;
}
return stocks.stream()
.mapToDouble(Stock::totalValue)
.sum();
}
}
Design problem identified: Using a List does not naturally allow entries from the same stock to be merged. The same stock added twice appears twice in the list, although the requirement asks to merge them.
7.3 PortfolioTestDrive.java
Path: src/main/java/com/tdd/m4/firstattempt/PortfolioTestDrive.java
package com.tdd.m4.firstattempt;
public class PortfolioTestDrive {
public static void main(String[] args) {
var portfolio = new Portfolio();
portfolio.add(new Stock("AAPL", 1, 140));
portfolio.add(new Stock("AAPL", 1, 120));
portfolio.add(new Stock("MSFT", 1, 200));
portfolio.printPortfolio();
System.out.println("=================================");
System.out.println(portfolio.totalValue());
}
}
Expected output (reveals problem):
{ AAPL | Qty: 1 | Px: 140.0 | Value: 140.0}
{ AAPL | Qty: 1 | Px: 120.0 | Value: 120.0}
{ MSFT | Qty: 1 | Px: 200.0 | Value: 200.0}
=================================
460.0
AAPL appears twice — does not meet merge requirement.
7.4 PortfolioTest.java (tests)
Path: src/test/java/com/tdd/m4/firstattempt/PortfolioTest.java
package com.tdd.m4.firstattempt;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class PortfolioTest {
@Test
public void emptyPortfolio_hasZeroValue() {
var portfolio = new Portfolio();
Assertions.assertEquals(0, portfolio.totalValue());
}
@Test
public void portfolioWithOneStock_calculatesTotalValue() {
int qty = 10;
double px = 260;
double value = qty * px;
var portfolio = new Portfolio();
portfolio.add(new Stock("MSFT", qty, px));
Assertions.assertEquals(value, portfolio.totalValue());
}
@Test
public void portfolioWithMultipleStocks_calculatesTotalValue() {
int microsoftQty = 10;
double microsoftPx = 260;
double microsoftValue = microsoftQty * microsoftPx;
int appleQty = 1;
double applePx = 150;
double appleValue = appleQty * applePx;
var portfolio = new Portfolio();
portfolio.add(new Stock("MSFT", microsoftQty, microsoftPx));
portfolio.add(new Stock("AAPL", appleQty, applePx));
Assertions.assertEquals(microsoftValue + appleValue, portfolio.totalValue());
}
@Test
public void portfolioWithAddedStockAtDifferentPrice_calculatesTotalValue() {
int appleQty_1 = 1;
double applePx_1 = 150;
double appleValue_1 = appleQty_1 * applePx_1;
int appleQty_2 = 2;
double applePx_2 = 100;
double appleValue_2 = appleQty_2 * applePx_2;
var portfolio = new Portfolio();
portfolio.add(new Stock("AAPL", appleQty_1, applePx_1));
portfolio.add(new Stock("AAPL", appleQty_2, applePx_2));
Assertions.assertEquals(appleValue_1 + appleValue_2, portfolio.totalValue());
System.out.println("Portfolio value: " + portfolio.totalValue());
}
}
8. Source code — Module 4: Portfolio (final design)
8.1 Stock.java (Java record)
Path: src/main/java/com/tdd/m4/Stock.java
package com.tdd.m4;
public record Stock(String symbol) {
}
Key points:
- Using a Java Record (available since Java 16, finalized in Java 17)
- A record is a pure immutable and data-bearing class
- Java automatically generates: the constructor (all fields),
getters(in the form of methods with the field name),hashCode(),equals(),toString() Stockonly contains symbol — trading data is inPosition
8.2 Position.java
Path: src/main/java/com/tdd/m4/Position.java
package com.tdd.m4;
public class Position {
Stock stock;
int qty;
double px;
public Position(Stock stock, int qty, double px) {
this.stock = stock;
this.qty = qty;
this.px = px;
}
public Stock getStock() {
return stock;
}
public int getQty() {
return qty;
}
public double getAveragePx() {
return px;
}
public double getValue() {
return qty * px;
}
public void setQuantity(int newQty) {
this.qty = newQty;
}
public void setAveragePx(double newAveragePx) {
this.px = newAveragePx;
}
@Override
public String toString() {
return String.format("{ %s | Qty: %s | Px: %s | Value: %s}",
stock, qty, px, qty * px);
}
}
Position Responsibilities:
- Represents the purchase of a stock by the investor
stock: reference toStock(public data)qty: quantity of shares heldpx: average purchase price (average price)getValue(): total value = qty × average pricesetQuantity()andsetAveragePx(): mutators needed for merging inPortfolio.add()
8.3 Portfolio.java (final)
Path: src/main/java/com/tdd/m4/Portfolio.java
package com.tdd.m4;
import java.util.HashMap;
import java.util.Map;
public class Portfolio {
private final Map<String, Position> positions;
public Portfolio() {
this.positions = new HashMap<>();
}
public Map<String, Position> getAllPositions() {
return positions;
}
public void add(Position position) {
String symbol = position.getStock().symbol();
// Si la position existe déjà, la mettre à jour
if (positions.containsKey(symbol)) {
Position existingPosition = positions.get(symbol);
int newQuantity = existingPosition.getQty() + position.getQty();
double newAveragePx = (existingPosition.getQty() * existingPosition.getAveragePx()
+ position.getQty() * position.getAveragePx()) / newQuantity;
existingPosition.setQuantity(newQuantity);
existingPosition.setAveragePx(newAveragePx);
} else {
positions.put(symbol, position);
}
}
public Position getPosition(String symbol) {
return positions.get(symbol);
}
public double getTotalValue() {
return positions.values().stream()
.mapToDouble(Position::getValue)
.sum();
}
public void print() {
positions.values()
.forEach(System.out::println);
System.out.println("===================================");
System.out.println(getTotalValue());
}
}
Key points of the final implementation:
-
HashMap<String, Position>: The key is the stock symbol. AHashMapguarantees the uniqueness of keys — a symbol can only appear once. -
Merge logic in
add(): When a symbol is already present:
- New quantity = existing quantity + new quantity
- New average price = weighted average: $\frac{qty_{ex} \times px_{ex} + qty_{new} \times px_{new}}{qty_{ex} + qty_{new}}$
-
getTotalValue(): Uses the Stream API to map each position to its value and sum them. -
TODO (challenges):
- Wrap the
Map— exposegetPositionCount()rather than the entireMap - Replace
doubleprimitives with a Money API (JSR-354 / JavaMoney) - Implement
remove()to sell positions
8.4 PortfolioPositionTest.java (tests)
Path: src/test/java/com/tdd/m4/PortfolioPositionTest.java
package com.tdd.m4;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class PortfolioPositionTest {
@Test
void emptyPortfolio_zeroPositions() {
var portfolio = new Portfolio();
Assertions.assertEquals(0, portfolio.getAllPositions().size());
}
@Test
void portfolioWithOnePosition_ReturnsThatPosition() {
var portfolio = new Portfolio();
String symbol = "MSFT";
portfolio.add(position(symbol, 10, 260));
Assertions.assertEquals(1, portfolio.getAllPositions().size());
Assertions.assertEquals(10, portfolio.getPosition(symbol).getQty());
Assertions.assertEquals(260, portfolio.getPosition(symbol).getAveragePx());
Assertions.assertEquals(2600, portfolio.getPosition(symbol).getValue());
}
@Test
void portfolioWithTwoDifferentPositions_ReturnsThosePositions() {
var portfolio = new Portfolio();
String microsoft = "MSFT";
String apple = "AAPL";
portfolio.add(position(microsoft, 10, 260));
portfolio.add(position(apple, 2, 150));
Assertions.assertEquals(2, portfolio.getAllPositions().size());
var microsoftPosition = portfolio.getPosition(microsoft);
Assertions.assertEquals(10, microsoftPosition.getQty());
Assertions.assertEquals(260, microsoftPosition.getAveragePx());
Assertions.assertEquals(2600, microsoftPosition.getValue());
var applePosition = portfolio.getPosition(apple);
Assertions.assertEquals(2, applePosition.getQty());
Assertions.assertEquals(150, applePosition.getAveragePx());
Assertions.assertEquals(300, applePosition.getValue());
}
// Factory method statique pour simplifier la création de Position dans les tests
static Position position(String symbol, int qty, double px) {
return new Position(new Stock(symbol), qty, px);
}
@Test
void portfolioWithSameStock_ReturnsOnePosition() {
var portfolio = new Portfolio();
String microsoft = "MSFT";
portfolio.add(position(microsoft, 10, 260));
portfolio.add(position(microsoft, 5, 200));
Assertions.assertEquals(1, portfolio.getAllPositions().size());
}
@Test
void portfolioWithSameStock_ReturnsCorrectQty() {
var portfolio = new Portfolio();
String microsoft = "MSFT";
portfolio.add(position(microsoft, 10, 260));
portfolio.add(position(microsoft, 1, 200));
Assertions.assertEquals(11, portfolio.getPosition(microsoft).getQty());
}
@Test
void portfolioWithSameStock_ReturnsCorrectAveragePrice() {
var portfolio = new Portfolio();
String microsoft = "MSFT";
portfolio.add(position(microsoft, 1, 240));
portfolio.add(position(microsoft, 1, 220));
Assertions.assertEquals(230, portfolio.getPosition("MSFT").getAveragePx());
}
@Test
void portfolioWithSameStock_ReturnsCorrectPositionValue() {
var portfolio = new Portfolio();
String microsoft = "MSFT";
portfolio.add(position(microsoft, 2, 240));
portfolio.add(position(microsoft, 1, 220));
double expected = 2 * 240 + 220;
Assertions.assertEquals(expected, portfolio.getPosition(microsoft).getValue());
}
@Test
void complexPortfolio_ReturnsCorrectTotalValue() {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 1, 260));
portfolio.add(position("MSFT", 2, 250));
portfolio.add(position("AAPL", 5, 90));
portfolio.add(position("AAPL", 10, 80));
portfolio.add(position("ORCL", 100, 80));
Assertions.assertEquals(3, portfolio.getAllPositions().size());
Assertions.assertEquals(10010, portfolio.getTotalValue());
}
}
Final design testing notes:
-
Factory method
position(): Simplifies the creation of objects in tests. Only one place to modify if thePositionconstructor changes. -
Test
emptyPortfolio_zeroPositions: ZOM Zero case. -
portfolioWithOnePosition_*tests: ZOM One case with multiple assertions on getters (choice justified in the module). -
portfolioWithSameStock_*tests: Series of progressive tests on merge logic — each test checks a different aspect (size, quantity, average price, value) — good practice not to put everything in a single test. -
Test
complexPortfolio_ReturnsCorrectTotalValue: Internal integration test combining all aspects — 5 adds, 3 positions merged, total value checked.
8.5 PositionsTestDrive.java
Path: src/main/java/com/tdd/m4/PositionsTestDrive.java
package com.tdd.m4;
public class PositionsTestDrive {
public static void main(String[] args) {
var portfolio = new Portfolio();
portfolio.add(position("MSFT", 1, 260));
portfolio.add(position("MSFT", 2, 250));
portfolio.add(position("AAPL", 5, 90));
portfolio.add(position("AAPL", 10, 80));
portfolio.add(position("ORCL", 100, 80));
portfolio.print();
}
private static Position position(String symbol, int qty, double px) {
return new Position(new Stock(symbol), qty, px);
}
}
Expected output:
{ MSFT | Qty: 3 | Px: 253.33333333333334 | Value: 760.0}
{ AAPL | Qty: 15 | Px: 83.33333333333333 | Value: 1250.0}
{ ORCL | Qty: 100 | Px: 80.0 | Value: 8000.0}
===================================
10010.0
Checking:
- MSFT: 1×260 + 2×250 = 760 ✓ | qty = 3 ✓ | average price = (260 + 500) / 3 ≈ 253.33 ✓
- AAPL: 5×90 + 10×80 = 1250 ✓ | qty = 15 ✓ | average price = (450 + 800) / 15 ≈ 83.33 ✓
- ORCL: 100×80 = 8000 ✓
- Total = 760 + 1250 + 8000 = 10010 ✓
Search Terms
tdd · java · backend · architecture · full-stack · web · test · design · portfolio · tests · attempt · source · additional · challenges · cycle · portfolio.java · requirements · rgr · stock.java · value