Build a Nutrition Score Calculator API
Build a REST API that grades food nutrition using TDD in Spring Boot.
Introduction
30 Second Summary
Every packaged food in Europe carries a letter grade from A to E that tells you how healthy it is at a glance. Behind that simple label is a scoring algorithm that crunches numbers like sugar content, fiber, and sodium into a single verdict.
In this project, you will build a REST API that implements this scoring algorithm from scratch using Spring Boot and Test-Driven Development. You will write failing tests first, then implement the code to make them pass.
What You'll Build
A Spring Boot REST API that accepts nutritional data for a food product and returns a health score breakdown with a final Nutri-Score grade (A through E).
By the end of this project, you'll have:
- A Spring Boot project with layered architecture (Controller, Service, Calculator) that scores food items from A to E.
- A full test suite written test-first using JUnit 5 and Mockito, covering unit tests and controller slice tests.
- A clean, SOLID-structured codebase with scoring thresholds externalized in YAML configuration.
- Secret Mission: a /compare endpoint that accepts two food items and returns a detailed comparison showing which is healthier and why.
Are there any prerequisites?
You should have basic Java knowledge (classes, interfaces, methods) and familiarity with HTTP concepts like GET, POST, and JSON. No prior Spring Boot or testing experience is needed.
Set Up the Spring Boot Project
Before you can write tests or build an API, you need a project skeleton. Spring Boot provides a web-based generator called Spring Initializr that scaffolds a ready-to-run project in seconds.
In this step, you will generate a Spring Boot project with Maven as the build tool, open it in IntelliJ IDEA, and lock it into version control with Git.
In this step, get ready to:
- Generate a Spring Boot project from start.spring.io with Maven, Java 17, and the Spring Web dependency.
- Open the project in IntelliJ IDEA and verify it compiles and runs.
- Initialize a Git repository and make your first commit.
Generate the Spring Boot project
Spring Initializr is a web tool that builds a fully configured project zip for you. You pick your build tool, Java version, and dependencies, and it handles the rest.
- Open your browser and navigate to https://start.spring.io.
- Under Project, select Maven.
- Under Language, select Java.
- Under Spring Boot, select the latest 3.5.x version available.
- In the Project Metadata section, set Group to com.example.nutriscore.
- Set Artifact to nutriscore-api.
- Set Name to nutriscore-api.
- Set Packaging to Jar.
- Set Java to 17.
- Click ADD DEPENDENCIES on the right side of the page.
- Search for Spring Web and select it.
- Click GENERATE at the bottom of the page.
A zip file called nutriscore-api.zip downloads to your computer.
- Navigate to your Downloads folder.
- Extract nutriscore-api.zip to your Desktop.
You should now have a folder called nutriscore-api on your Desktop containing the project files.
What is Spring Initializr?
Spring Initializr is a project generator maintained by the Spring team. It creates a pre-configured project structure with all the boilerplate files you need: a build file, a main application class, and a Maven wrapper so you do not need Maven installed on your machine.
Why Maven over Gradle?
Maven is the dominant build tool in enterprise Java environments. Its explicit XML configuration reduces hidden "magic" compared to Gradle's Groovy DSL, making it easier to debug build issues when you are getting back up to speed.
Don't see a 3.5.x version?
Spring Initializr may default to a newer version like 4.x. If 3.5.x is not listed, select the latest 3.x version available. If only 4.x is offered, that works too. No code changes are needed.
Help me pick the right Spring Boot version on start.spring.io.
Open and run the project in IntelliJ IDEA
With the project extracted, you can open it in IntelliJ IDEA and confirm it compiles. The Maven wrapper included in the project handles downloading the correct Maven version automatically.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ and press Enter to open it.
- Click Open on the Welcome screen.
- Navigate to your Desktop and select the nutriscore-api folder, then click OK.
What happens when IntelliJ opens the project?
IntelliJ detects the pom.xml file and starts downloading dependencies in the background. A progress bar appears at the bottom of the window. Wait for it to finish before running the project.
Once IntelliJ finishes importing, you can run the application to confirm everything compiled correctly.
- Open the built-in terminal in IntelliJ by clicking Terminal at the bottom of the window (or press Alt+F12).
- Start the application by running this command:
macOS
./mvnw spring-boot:run
Windows
mvnw.cmd spring-boot:run
What does this command do?
The Maven wrapper (mvnw on macOS, mvnw.cmd on Windows) downloads the correct Maven version for you, compiles all Java source files, and starts the embedded Tomcat web server.
After a few seconds, you should see output ending with a line like:
Started NutriscoreApiApplication in X.XX seconds
This confirms the application compiles and the embedded web server starts successfully on port 8080.
- Press Ctrl+C in the terminal to stop the application.
Seeing an error instead?
If you see 'mvnw.cmd' is not recognized, make sure your terminal is open inside the nutriscore-api folder (not a parent folder).
If you see a JAVA_HOME error, confirm Java 17 is installed by running java -version in your terminal.
My Spring Boot application won't start. Help me debug the error.
Initialize a Git repository and make the first commit
Version control lets you track each stage of the TDD cycle as you build the project. Starting with a commit of the clean generated project gives you a known-good baseline to return to.
- In the IntelliJ terminal (still inside the nutriscore-api folder), initialize a Git repository by running:
git init
- Stage all the generated files and make your first commit by running these commands:
git add .
git commit -m "Initial Spring Boot project from start.spring.io"
What does this do?
The git init command creates a new Git repository in the current folder. Then git add . stages every file, and git commit saves them as your first snapshot.
You should see output confirming the commit with a list of files created.
Getting a git config error?
If Git asks you to set your name and email, run these two commands (replacing the placeholder values with your own):
git config --global user.name "Your Name"
git config --global user.email "your@email.com"
Then re-run the git commit command.
✔️ Awesome, I've got everything!
Great. Double check you have the nutriscore-api project open in IntelliJ with a successful Git commit.
ⓧ I'd like to double check the full code
Here is the auto-generated main application class that Spring Initializr created for you. Verify your file matches:
package com.example.nutriscore;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class NutriscoreApiApplication {
public static void main(String[] args) {
SpringApplication.run(NutriscoreApiApplication.class, args);
}
}
This file lives at src/main/java/com/example/nutriscore/NutriscoreApiApplication.java inside your project folder.
Your Spring Boot project is ready and under version control. Next up, you will write your first test before a single line of production code exists.
Write the Scoring Algorithm Test-First
Your project compiles and runs, which means the foundation is solid. Now it is time to build the actual scoring logic that makes your API useful.
In Test-Driven Development, you write the test before you write the code. The test defines what "correct" looks like, and then you write just enough implementation to make it pass. This step walks you through that red-green cycle for the Nutri-Score calculation algorithm.
In this step, get ready to:
- Create a test class for the scoring calculator with negative point test cases.
- Run the tests, watch them fail, then implement the calculator to make them pass.
- Add positive point test cases and implement that scoring logic.
Create the configuration properties class
The scoring algorithm compares nutritional values against threshold tables. Each nutrient (energy, sugars, fat, sodium) has a list of cutoff values that determine how many penalty points it earns.
Before writing the test, you need a class to hold these thresholds. This class will eventually bind to YAML configuration, but for now the test will populate it manually.
- In IntelliJ IDEA, right-click the src/main/java/com/example/nutriscore folder in the Project panel on the left.
- Select New, then Package.
- Enter config as the package name and press Enter.
- Right-click the new config package, select New then Java Class.
- Name it NutriScoreProperties and press Enter.
- Replace the generated file content with the following code:
package com.example.nutriscore.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.List;
// Holds all scoring thresholds and grade boundaries
// Spring Boot will bind YAML values to this class later
@Configuration
@ConfigurationProperties(prefix = "nutriscore")
public class NutriScoreProperties {
private Thresholds thresholds;
private Grades grades;
public Thresholds getThresholds() {
return thresholds;
}
public void setThresholds(Thresholds thresholds) {
this.thresholds = thresholds;
}
public Grades getGrades() {
return grades;
}
public void setGrades(Grades grades) {
this.grades = grades;
}
// Each list contains threshold cutoffs for one nutrient
// A value above the Nth threshold earns N points
public static class Thresholds {
private List<Double> energy;
private List<Double> saturatedFat;
private List<Double> sugars;
private List<Double> sodium;
private List<Double> fiber;
private List<Double> protein;
private List<Double> fruitVegetable;
public List<Double> getEnergy() { return energy; }
public void setEnergy(List<Double> energy) { this.energy = energy; }
public List<Double> getSaturatedFat() { return saturatedFat; }
public void setSaturatedFat(List<Double> saturatedFat) { this.saturatedFat = saturatedFat; }
public List<Double> getSugars() { return sugars; }
public void setSugars(List<Double> sugars) { this.sugars = sugars; }
public List<Double> getSodium() { return sodium; }
public void setSodium(List<Double> sodium) { this.sodium = sodium; }
public List<Double> getFiber() { return fiber; }
public void setFiber(List<Double> fiber) { this.fiber = fiber; }
public List<Double> getProtein() { return protein; }
public void setProtein(List<Double> protein) { this.protein = protein; }
public List<Double> getFruitVegetable() { return fruitVegetable; }
public void setFruitVegetable(List<Double> fruitVegetable) { this.fruitVegetable = fruitVegetable; }
}
// Grade boundaries: final score <= aMax is grade A, etc.
public static class Grades {
private int aMax;
private int bMax;
private int cMax;
private int dMax;
public int getAMax() { return aMax; }
public void setAMax(int aMax) { this.aMax = aMax; }
public int getBMax() { return bMax; }
public void setBMax(int bMax) { this.bMax = bMax; }
public int getCMax() { return cMax; }
public void setCMax(int cMax) { this.cMax = cMax; }
public int getDMax() { return dMax; }
public void setDMax(int dMax) { this.dMax = dMax; }
}
}
What does this class do?
This is a plain Java class with getters and setters. The Thresholds inner class holds lists of cutoff values for each nutrient. The Grades inner class defines the score boundaries for grades A through E.
The @ConfigurationProperties annotation tells Spring Boot to automatically map YAML values into this class. You will wire up the YAML file in a later step. For now, the test will set these values manually.
- Save NutriScoreProperties.java with Ctrl+S.
Create a shell calculator class
For your test to compile, the class it tests must exist. In TDD, you create the minimum shell needed for compilation, then let the test drive the real implementation.
- Right-click the src/main/java/com/example/nutriscore folder.
- Select New, then Package, and enter calculator.
- Right-click the calculator package, select New then Java Class, and name it NutriScoreCalculator.
- Replace the generated file content with this shell:
package com.example.nutriscore.calculator;
import com.example.nutriscore.config.NutriScoreProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class NutriScoreCalculator {
private final NutriScoreProperties properties;
public NutriScoreCalculator(NutriScoreProperties properties) {
this.properties = properties;
}
// Shell method - returns 0 so the test compiles
public int calculateNegativePoints(double energyKj, double saturatedFat, double sugars, double sodium) {
return 0;
}
// Shell method - returns 0 so the test compiles
public int calculatePositivePoints(double fiber, double protein, double fruitVegetablePercent) {
return 0;
}
}
Why return 0 instead of the real logic?
This is the TDD pattern. You write just enough code to make the test compile, but not enough to make it pass. The test defines what correct output looks like. Then you fill in the real logic to satisfy the test.
- Save NutriScoreCalculator.java.
Write test cases for negative points
The Nutri-Score algorithm scores "bad" nutrients (energy, saturated fat, sugars, sodium) as negative points. Each nutrient can earn 0 to 10 points based on threshold tables. Higher values mean a worse health score.
- Right-click the src/test/java/com/example/nutriscore folder.
- Select New, then Package, and enter calculator.
- Right-click the calculator test package, select New then Java Class, and name it NutriScoreCalculatorTest.
- Replace the generated file content with the following test class:
package com.example.nutriscore.calculator;
import com.example.nutriscore.config.NutriScoreProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NutriScoreCalculatorTest {
private NutriScoreCalculator calculator;
@BeforeEach
void setUp() {
// Manually create properties with real Nutri-Score thresholds
NutriScoreProperties properties = new NutriScoreProperties();
NutriScoreProperties.Thresholds thresholds = new NutriScoreProperties.Thresholds();
thresholds.setEnergy(List.of(335.0, 670.0, 1005.0, 1340.0, 1675.0, 2010.0, 2345.0, 2680.0, 3015.0, 3350.0));
thresholds.setSaturatedFat(List.of(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0));
thresholds.setSugars(List.of(4.5, 9.0, 13.5, 18.0, 22.5, 27.0, 31.0, 36.0, 40.0, 45.0));
thresholds.setSodium(List.of(90.0, 180.0, 270.0, 360.0, 450.0, 540.0, 630.0, 720.0, 810.0, 900.0));
thresholds.setFiber(List.of(0.9, 1.9, 2.8, 3.7, 4.7));
thresholds.setProtein(List.of(1.6, 3.2, 4.8, 6.4, 8.0));
thresholds.setFruitVegetable(List.of(40.0, 60.0, 80.0));
properties.setThresholds(thresholds);
calculator = new NutriScoreCalculator(properties);
}
@Test
@DisplayName("Zero values should return 0 negative points")
void zeroValues_returnsZeroNegativePoints() {
int result = calculator.calculateNegativePoints(0, 0, 0, 0);
assertEquals(0, result);
}
@Test
@DisplayName("Maximum values should return 40 negative points")
void maxValues_returnsMaxNegativePoints() {
// 4 nutrients x 10 max points each = 40
int result = calculator.calculateNegativePoints(4000, 15, 50, 1000);
assertEquals(40, result);
}
@Test
@DisplayName("Energy of 500 kJ should score 1 point")
void energy500_scores1Point() {
// 500 is above the first threshold (335) but below the second (670)
int result = calculator.calculateNegativePoints(500, 0, 0, 0);
assertEquals(1, result);
}
@Test
@DisplayName("Boundary value: exactly at threshold scores 0")
void exactThreshold_scoresZero() {
// Exactly 335 does NOT exceed the threshold (must be strictly greater)
int result = calculator.calculateNegativePoints(335, 0, 0, 0);
assertEquals(0, result);
}
@Test
@DisplayName("Just above threshold scores 1")
void justAboveThreshold_scoresOne() {
// 335.1 is just above the first energy threshold
int result = calculator.calculateNegativePoints(335.1, 0, 0, 0);
assertEquals(1, result);
}
}
What does this test class do?
The @BeforeEach method runs before every test. It creates a NutriScoreProperties object with real Nutri-Score thresholds and passes it to the calculator. No Spring context is needed because this is a pure unit test.
Each @Test method verifies one specific behaviour. The boundary tests (exactly at threshold vs. just above) catch off-by-one errors, which are the most common bug in threshold-based scoring.
Why not use Spring's test context here?
This test directly instantiates the calculator with manually-set properties. It does not load the Spring application context. Pure unit tests like this run in milliseconds because they have zero framework overhead.
You will use Spring's test slices (like @WebMvcTest) later when testing the HTTP controller layer. The test pyramid principle says: use the lightest test type that still covers the behaviour.
- Save NutriScoreCalculatorTest.java.
- Verify that IntelliJ shows no red underlines in your test file. If you see errors, check that the import paths match the packages you created.
Seeing red underlines in the test file?
Make sure NutriScoreProperties.java is in the com.example.nutriscore.config package and NutriScoreCalculator.java is in com.example.nutriscore.calculator. The test imports must match these exact package paths.
If IntelliJ shows "Cannot resolve symbol", try clicking File then Invalidate Caches / Restart to refresh the project index.
Help me fix import errors in my JUnit test class.
Run the tests and watch them fail
This is the "red" phase of TDD. The tests define what correct behaviour looks like, and right now the shell calculator returns 0 for everything. Most of your tests expect non-zero results, so they will fail.
- Open a terminal in IntelliJ by clicking View then Tool Windows then Terminal.
- Run the tests by executing this command:
mvnw.cmd test -Dtest=NutriScoreCalculatorTest
You should see output showing that 3 tests fail and 2 tests pass. The two that pass are the "zero values" tests, because 0 happens to match the shell's hardcoded return value.
Why is this failure a good thing?
A failing test proves that the test is actually checking something meaningful. If all tests passed with a shell that returns 0, the tests would be worthless. The red phase confirms your tests have teeth.
Seeing a compilation error instead of test failures?
If Maven cannot compile the test, check that the class names in your imports exactly match the class files you created. The package com.example.nutriscore.calculator must exist in both src/main/java and src/test/java.
Help me fix compilation errors when running my Maven tests.
Implement the negative points calculation
Now for the "green" phase. You will replace the shell methods with real scoring logic that iterates through threshold lists and counts how many thresholds each value exceeds.
- Open NutriScoreCalculator.java in the calculator package under src/main/java.
- Replace the entire file content with the following implementation:
package com.example.nutriscore.calculator;
import com.example.nutriscore.config.NutriScoreProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class NutriScoreCalculator {
private final NutriScoreProperties properties;
// Constructor injection - Spring provides the properties automatically
public NutriScoreCalculator(NutriScoreProperties properties) {
this.properties = properties;
}
// Calculates penalty points for unhealthy nutrients
// Each nutrient scores 0-10 based on its threshold list
public int calculateNegativePoints(double energyKj, double saturatedFat, double sugars, double sodium) {
int energyPoints = calculatePoints(energyKj, properties.getThresholds().getEnergy());
int fatPoints = calculatePoints(saturatedFat, properties.getThresholds().getSaturatedFat());
int sugarPoints = calculatePoints(sugars, properties.getThresholds().getSugars());
int sodiumPoints = calculatePoints(sodium, properties.getThresholds().getSodium());
return energyPoints + fatPoints + sugarPoints + sodiumPoints;
}
// Calculates reward points for healthy nutrients
public int calculatePositivePoints(double fiber, double protein, double fruitVegetablePercent) {
int fiberPoints = calculatePoints(fiber, properties.getThresholds().getFiber());
int proteinPoints = calculatePoints(protein, properties.getThresholds().getProtein());
int fruitVegPoints = calculatePoints(fruitVegetablePercent, properties.getThresholds().getFruitVegetable());
return fiberPoints + proteinPoints + fruitVegPoints;
}
// Generic helper: counts how many thresholds the value exceeds
// Stops at the first threshold NOT exceeded (thresholds are sorted ascending)
private int calculatePoints(double value, List<Double> thresholds) {
int points = 0;
for (Double threshold : thresholds) {
if (value > threshold) {
points++;
} else {
break;
}
}
return points;
}
}
What does this implementation do?
The calculatePoints helper is the core of the algorithm. It loops through a sorted list of thresholds and counts how many the input value exceeds. Because thresholds are ascending, it stops at the first one the value does not beat.
Both calculateNegativePoints and calculatePositivePoints delegate to this same helper. This follows the DRY principle: one algorithm, applied to different threshold lists.
- Save NutriScoreCalculator.java.
- Run the tests again by executing this command:
mvnw.cmd test -Dtest=NutriScoreCalculatorTest
You should see all 5 tests pass. The output shows Tests run: 5, Failures: 0.
Tests still failing after implementing?
Check that your calculatePoints method uses value > threshold (strictly greater than), not >=. The boundary test expects that a value exactly equal to a threshold scores 0 points.
Help me debug why my NutriScoreCalculator tests are still failing.
Add positive point tests and verify they pass
The Nutri-Score also rewards healthy nutrients: fiber, protein, and fruit/vegetable content. These earn "positive" points that offset the negative ones. You will repeat the TDD cycle for these.
- Open NutriScoreCalculatorTest.java.
- Add the following three test methods at the bottom of the class, after the last existing test method:
@Test
@DisplayName("Zero values should return 0 positive points")
void zeroValues_returnsZeroPositivePoints() {
int result = calculator.calculatePositivePoints(0, 0, 0);
assertEquals(0, result);
}
@Test
@DisplayName("High fiber, protein, and fruit percent should return max positive points")
void highValues_returnsMaxPositivePoints() {
// Fiber max 5 + Protein max 5 + Fruit/Veg max 3 = 13
int result = calculator.calculatePositivePoints(5.0, 9.0, 85);
assertEquals(13, result);
}
@Test
@DisplayName("Moderate fiber scores correctly")
void moderateFiber_scoresCorrectly() {
// 2.0g fiber exceeds thresholds at 0.9 and 1.9, so scores 2
int result = calculator.calculatePositivePoints(2.0, 0, 0);
assertEquals(2, result);
}
What do these tests verify?
The zero-values test confirms a baseline. The max-values test verifies the ceiling: fiber has 5 thresholds (max 5 points), protein has 5 thresholds (max 5 points), and fruit/vegetable has 3 thresholds (max 3 points), totalling 13.
The moderate fiber test catches precision issues. A value of 2.0 exceeds thresholds at 0.9 and 1.9 but not 2.8, so it should score exactly 2.
- Save NutriScoreCalculatorTest.java.
- Run the full test class again by executing this command:
mvnw.cmd test -Dtest=NutriScoreCalculatorTest
You should see all 8 tests pass. The positive point logic already works because it uses the same calculatePoints helper you implemented for negative points.
Why did the positive tests pass immediately?
When you implemented the full NutriScoreCalculator in the previous substep, you included calculatePositivePoints because it reuses the same calculatePoints helper. Writing the tests afterward still follows TDD thinking. You defined the expected behaviour, ran the tests, and confirmed correctness.
Seeing a failure on the max positive points test?
Double check that your @BeforeEach sets up the fiber thresholds as List.of(0.9, 1.9, 2.8, 3.7, 4.7), protein as List.of(1.6, 3.2, 4.8, 6.4, 8.0), and fruitVegetable as List.of(40.0, 60.0, 80.0). Mistyped threshold values will produce unexpected point totals.
Help me debug my positive points test failure.
- Commit your progress by running these commands:
git add .
git commit -m "Add NutriScoreCalculator with TDD tests for negative and positive points"
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or modified in this step.
package com.example.nutriscore.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
@ConfigurationProperties(prefix = "nutriscore")
public class NutriScoreProperties {
private Thresholds thresholds;
private Grades grades;
public Thresholds getThresholds() {
return thresholds;
}
public void setThresholds(Thresholds thresholds) {
this.thresholds = thresholds;
}
public Grades getGrades() {
return grades;
}
public void setGrades(Grades grades) {
this.grades = grades;
}
public static class Thresholds {
private List<Double> energy;
private List<Double> saturatedFat;
private List<Double> sugars;
private List<Double> sodium;
private List<Double> fiber;
private List<Double> protein;
private List<Double> fruitVegetable;
public List<Double> getEnergy() { return energy; }
public void setEnergy(List<Double> energy) { this.energy = energy; }
public List<Double> getSaturatedFat() { return saturatedFat; }
public void setSaturatedFat(List<Double> saturatedFat) { this.saturatedFat = saturatedFat; }
public List<Double> getSugars() { return sugars; }
public void setSugars(List<Double> sugars) { this.sugars = sugars; }
public List<Double> getSodium() { return sodium; }
public void setSodium(List<Double> sodium) { this.sodium = sodium; }
public List<Double> getFiber() { return fiber; }
public void setFiber(List<Double> fiber) { this.fiber = fiber; }
public List<Double> getProtein() { return protein; }
public void setProtein(List<Double> protein) { this.protein = protein; }
public List<Double> getFruitVegetable() { return fruitVegetable; }
public void setFruitVegetable(List<Double> fruitVegetable) { this.fruitVegetable = fruitVegetable; }
}
public static class Grades {
private int aMax;
private int bMax;
private int cMax;
private int dMax;
public int getAMax() { return aMax; }
public void setAMax(int aMax) { this.aMax = aMax; }
public int getBMax() { return bMax; }
public void setBMax(int bMax) { this.bMax = bMax; }
public int getCMax() { return cMax; }
public void setCMax(int cMax) { this.cMax = cMax; }
public int getDMax() { return dMax; }
public void setDMax(int dMax) { this.dMax = dMax; }
}
}
package com.example.nutriscore.calculator;
import com.example.nutriscore.config.NutriScoreProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class NutriScoreCalculator {
private final NutriScoreProperties properties;
public NutriScoreCalculator(NutriScoreProperties properties) {
this.properties = properties;
}
public int calculateNegativePoints(double energyKj, double saturatedFat, double sugars, double sodium) {
int energyPoints = calculatePoints(energyKj, properties.getThresholds().getEnergy());
int fatPoints = calculatePoints(saturatedFat, properties.getThresholds().getSaturatedFat());
int sugarPoints = calculatePoints(sugars, properties.getThresholds().getSugars());
int sodiumPoints = calculatePoints(sodium, properties.getThresholds().getSodium());
return energyPoints + fatPoints + sugarPoints + sodiumPoints;
}
public int calculatePositivePoints(double fiber, double protein, double fruitVegetablePercent) {
int fiberPoints = calculatePoints(fiber, properties.getThresholds().getFiber());
int proteinPoints = calculatePoints(protein, properties.getThresholds().getProtein());
int fruitVegPoints = calculatePoints(fruitVegetablePercent, properties.getThresholds().getFruitVegetable());
return fiberPoints + proteinPoints + fruitVegPoints;
}
private int calculatePoints(double value, List<Double> thresholds) {
int points = 0;
for (Double threshold : thresholds) {
if (value > threshold) {
points++;
} else {
break;
}
}
return points;
}
}
package com.example.nutriscore.calculator;
import com.example.nutriscore.config.NutriScoreProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NutriScoreCalculatorTest {
private NutriScoreCalculator calculator;
@BeforeEach
void setUp() {
NutriScoreProperties properties = new NutriScoreProperties();
NutriScoreProperties.Thresholds thresholds = new NutriScoreProperties.Thresholds();
thresholds.setEnergy(List.of(335.0, 670.0, 1005.0, 1340.0, 1675.0, 2010.0, 2345.0, 2680.0, 3015.0, 3350.0));
thresholds.setSaturatedFat(List.of(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0));
thresholds.setSugars(List.of(4.5, 9.0, 13.5, 18.0, 22.5, 27.0, 31.0, 36.0, 40.0, 45.0));
thresholds.setSodium(List.of(90.0, 180.0, 270.0, 360.0, 450.0, 540.0, 630.0, 720.0, 810.0, 900.0));
thresholds.setFiber(List.of(0.9, 1.9, 2.8, 3.7, 4.7));
thresholds.setProtein(List.of(1.6, 3.2, 4.8, 6.4, 8.0));
thresholds.setFruitVegetable(List.of(40.0, 60.0, 80.0));
properties.setThresholds(thresholds);
calculator = new NutriScoreCalculator(properties);
}
@Test
@DisplayName("Zero values should return 0 negative points")
void zeroValues_returnsZeroNegativePoints() {
int result = calculator.calculateNegativePoints(0, 0, 0, 0);
assertEquals(0, result);
}
@Test
@DisplayName("Maximum values should return 40 negative points")
void maxValues_returnsMaxNegativePoints() {
int result = calculator.calculateNegativePoints(4000, 15, 50, 1000);
assertEquals(40, result);
}
@Test
@DisplayName("Energy of 500 kJ should score 1 point")
void energy500_scores1Point() {
int result = calculator.calculateNegativePoints(500, 0, 0, 0);
assertEquals(1, result);
}
@Test
@DisplayName("Boundary value: exactly at threshold scores 0")
void exactThreshold_scoresZero() {
int result = calculator.calculateNegativePoints(335, 0, 0, 0);
assertEquals(0, result);
}
@Test
@DisplayName("Just above threshold scores 1")
void justAboveThreshold_scoresOne() {
int result = calculator.calculateNegativePoints(335.1, 0, 0, 0);
assertEquals(1, result);
}
@Test
@DisplayName("Zero values should return 0 positive points")
void zeroValues_returnsZeroPositivePoints() {
int result = calculator.calculatePositivePoints(0, 0, 0);
assertEquals(0, result);
}
@Test
@DisplayName("High fiber, protein, and fruit percent should return max positive points")
void highValues_returnsMaxPositivePoints() {
int result = calculator.calculatePositivePoints(5.0, 9.0, 85);
assertEquals(13, result);
}
@Test
@DisplayName("Moderate fiber scores correctly")
void moderateFiber_scoresCorrectly() {
int result = calculator.calculatePositivePoints(2.0, 0, 0);
assertEquals(2, result);
}
}
Your scoring algorithm is complete and fully tested. Next up, you will move those hardcoded threshold values out of the test and into a YAML configuration file that Spring Boot manages for you.
Externalize Scoring Thresholds in YAML
Your scoring algorithm works and all tests pass. However, the threshold values that define how many points each nutrient gets are only available inside your test setup code right now.
When the actual Spring Boot application runs, it needs a real source for those values. In this step, you will create an application.yml file that feeds threshold data to your NutriScoreProperties class through Spring Boot's @ConfigurationProperties binding.
In this step, get ready to:
- Create an application.yml with scoring thresholds for every nutrient and grade boundaries.
- Verify all existing tests still pass after the refactor.
Create the YAML configuration file
Your NutriScoreProperties class already has the @ConfigurationProperties(prefix = "nutriscore") annotation from the previous step. Spring Boot automatically maps YAML keys to the matching Java fields in that class. All you need to do is provide the YAML file with the correct structure.
- In the IntelliJ Project tool window, expand src/main/resources.
- You should see an existing application.properties file that Spring Initializr generated. Right-click it and select Refactor, then Rename.
- Rename it to application.yml and press Enter.
- Open application.yml and replace any existing content with the following configuration:
spring:
application:
name: nutriscore-api
nutriscore:
thresholds:
energy: [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350]
saturated-fat: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
sugars: [4.5, 9, 13.5, 18, 22.5, 27, 31, 36, 40, 45]
sodium: [90, 180, 270, 360, 450, 540, 630, 720, 810, 900]
fiber: [0.9, 1.9, 2.8, 3.7, 4.7]
protein: [1.6, 3.2, 4.8, 6.4, 8.0]
fruit-vegetable: [40, 60, 80]
grades:
a-max: -1
b-max: 2
c-max: 10
d-max: 18
What does this configuration do?
The nutriscore.thresholds section defines the scoring breakpoints for each nutrient. Each array represents the boundary values where a food product earns an additional penalty or bonus point.
The nutriscore.grades section sets the final score cutoffs for each letter grade. For example, any final score of -1 or below earns an A (the healthiest rating).
Spring Boot automatically binds saturated-fat in YAML to the saturatedFat Java field. Kebab-case in YAML maps to camelCase in Java through relaxed binding.
Why externalize thresholds instead of hardcoding them?
Hardcoded values require a code change and recompile every time scoring rules update. By storing thresholds in YAML, you can adjust the scoring algorithm by editing a configuration file alone.
This is a daily task in enterprise Java environments. Different deployment environments (dev, staging, production) can use different config files without touching application code.
- Save application.yml.
Seeing red underlines in the YAML file?
Make sure the indentation uses exactly 2 spaces per level. YAML is whitespace-sensitive, so a misaligned key breaks the structure.
Check that the file is named application.yml (not application.yaml or application.yml.txt). Both .yml and .yaml extensions work, but the file must live directly inside src/main/resources.
Help me troubleshoot my application.yml configuration
Run your tests to verify the refactor
One of the biggest benefits of TDD is that your tests act as a safety net during refactoring. You changed where threshold values live, so now you need to confirm nothing broke.
- Run your test suite by running this command in the IntelliJ terminal:
mvnw.cmd test
Why do the tests still pass without the YAML?
Your NutriScoreCalculatorTest constructs a NutriScoreProperties object manually in the @BeforeEach method. It never loads the Spring application context, so it does not read from application.yml.
This is intentional. Pure unit tests stay fast and isolated. The YAML file serves the real application at runtime and integration tests later.
- You should see all 8 tests pass with a BUILD SUCCESS message at the end of the output.
Tests failing after the YAML change?
If you see compilation errors, make sure you did not accidentally modify NutriScoreProperties.java or NutriScoreCalculator.java. Those files should be unchanged from the previous step.
If you see a Spring context error mentioning application.yml, check that the nutriscore key in YAML is at the root level (no extra indentation) and that all nested keys are properly indented with 2 or 4 spaces.
Help me fix my failing tests after adding application.yml
- Commit your progress by running these commands:
git add .
git commit -m "Externalize scoring thresholds in application.yml"
✔️ Awesome, I've got everything!
Great. Double check you have saved application.yml and that your tests still pass.
ⓧ I'd like to double check the full code
Here is the complete application.yml file. Make sure yours matches exactly.
spring:
application:
name: nutriscore-api
nutriscore:
thresholds:
energy: [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350]
saturated-fat: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
sugars: [4.5, 9, 13.5, 18, 22.5, 27, 31, 36, 40, 45]
sodium: [90, 180, 270, 360, 450, 540, 630, 720, 810, 900]
fiber: [0.9, 1.9, 2.8, 3.7, 4.7]
protein: [1.6, 3.2, 4.8, 6.4, 8.0]
fruit-vegetable: [40, 60, 80]
grades:
a-max: -1
b-max: 2
c-max: 10
d-max: 18
Your NutriScoreProperties.java and NutriScoreCalculator.java should be unchanged from the previous step.
Your scoring algorithm is now backed by real configuration that Spring Boot reads at startup. Next up, you will build the service layer that combines negative and positive points into a final Nutri-Score grade.
Build the Service Layer with Grade Calculation
Your calculator knows how to score individual nutrients. Now you need a layer that combines those scores into a final grade (A through E) that a consumer can understand at a glance.
The service layer sits between the controller (which handles HTTP requests) and the calculator (which crunches numbers). It orchestrates the formula: subtract positive points from negative points, then map the result to a letter grade. Following TDD, you will write the test first, watch it fail, then build the service to make it pass.
In this step, get ready to:
- Write a test for NutriScoreService that verifies the score formula and grade assignment.
- Implement the service using constructor injection and the existing calculator component.
- Add edge case tests for boundary scores between each grade.
Write the service test (TDD-first)
The service test needs data transfer objects (DTOs) to represent input and output. You will create these first so the test compiles, then write the test before any service implementation exists.
The DTOs also include validation annotations for use in a later step. This requires the spring-boot-starter-validation dependency.
- Open pom.xml in IntelliJ IDEA.
- Find the <dependencies> section and add this dependency below the existing spring-boot-starter-web entry:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
What does this dependency do?
This starter adds Jakarta Bean Validation to your project. It lets you annotate DTO fields with constraints like @NotNull and @Min. Spring Boot manages the version automatically through the parent POM.
- Save pom.xml.
- When IntelliJ shows a notification to reload Maven changes, click the reload icon or press Ctrl+Shift+O to reimport.
Now create the request DTO that represents a food item's nutritional data.
- In the Project panel on the left side of IntelliJ, right-click the com.example.nutriscore package under src/main/java.
- Select New, then Package.
- Type dto and press Enter.
- Right-click the new dto package, select New, then Java Class.
- Name it FoodItemRequest and press Enter.
- Replace the entire file contents with the following code:
package com.example.nutriscore.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class FoodItemRequest {
@NotNull(message = "name is required")
private String name;
@NotNull(message = "energyKj is required")
@Min(value = 0, message = "energyKj must be non-negative")
private Double energyKj;
@NotNull(message = "saturatedFat is required")
@Min(value = 0, message = "saturatedFat must be non-negative")
private Double saturatedFat;
@NotNull(message = "sugars is required")
@Min(value = 0, message = "sugars must be non-negative")
private Double sugars;
@NotNull(message = "sodium is required")
@Min(value = 0, message = "sodium must be non-negative")
private Double sodium;
@NotNull(message = "fiber is required")
@Min(value = 0, message = "fiber must be non-negative")
private Double fiber;
@NotNull(message = "protein is required")
@Min(value = 0, message = "protein must be non-negative")
private Double protein;
@NotNull(message = "fruitVegetablePercent is required")
@Min(value = 0, message = "fruitVegetablePercent must be non-negative")
private Double fruitVegetablePercent;
// No-arg constructor for JSON deserialization
public FoodItemRequest() {}
// All-arg constructor for easy test setup
public FoodItemRequest(String name, Double energyKj, Double saturatedFat, Double sugars,
Double sodium, Double fiber, Double protein, Double fruitVegetablePercent) {
this.name = name;
this.energyKj = energyKj;
this.saturatedFat = saturatedFat;
this.sugars = sugars;
this.sodium = sodium;
this.fiber = fiber;
this.protein = protein;
this.fruitVegetablePercent = fruitVegetablePercent;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Double getEnergyKj() { return energyKj; }
public void setEnergyKj(Double energyKj) { this.energyKj = energyKj; }
public Double getSaturatedFat() { return saturatedFat; }
public void setSaturatedFat(Double saturatedFat) { this.saturatedFat = saturatedFat; }
public Double getSugars() { return sugars; }
public void setSugars(Double sugars) { this.sugars = sugars; }
public Double getSodium() { return sodium; }
public void setSodium(Double sodium) { this.sodium = sodium; }
public Double getFiber() { return fiber; }
public void setFiber(Double fiber) { this.fiber = fiber; }
public Double getProtein() { return protein; }
public void setProtein(Double protein) { this.protein = protein; }
public Double getFruitVegetablePercent() { return fruitVegetablePercent; }
public void setFruitVegetablePercent(Double fruitVegetablePercent) { this.fruitVegetablePercent = fruitVegetablePercent; }
}
What does this code do?
This DTO represents the nutritional data a client will send to the API. Each field maps to a nutrient the Nutri-Score algorithm evaluates.
The @NotNull and @Min annotations declare validation rules. They will be enforced by the controller in a later step. The all-arg constructor makes creating test instances easy without calling setters one by one.
- Save FoodItemRequest.java.
- Confirm there are no red underlines in the editor. The imports should resolve cleanly.
Now create the response DTO that the service will return after scoring.
- Right-click the dto package, select New, then Java Class.
- Name it ScoreResponse and press Enter.
- Replace the entire file contents with the following code:
package com.example.nutriscore.dto;
public class ScoreResponse {
private String name;
private int negativePoints;
private int positivePoints;
private int finalScore;
private String grade;
// No-arg constructor for JSON serialization
public ScoreResponse() {}
// All-arg constructor for building responses in the service
public ScoreResponse(String name, int negativePoints, int positivePoints, int finalScore, String grade) {
this.name = name;
this.negativePoints = negativePoints;
this.positivePoints = positivePoints;
this.finalScore = finalScore;
this.grade = grade;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getNegativePoints() { return negativePoints; }
public void setNegativePoints(int negativePoints) { this.negativePoints = negativePoints; }
public int getPositivePoints() { return positivePoints; }
public void setPositivePoints(int positivePoints) { this.positivePoints = positivePoints; }
public int getFinalScore() { return finalScore; }
public void setFinalScore(int finalScore) { this.finalScore = finalScore; }
public String getGrade() { return grade; }
public void setGrade(String grade) { this.grade = grade; }
}
What does this code do?
The ScoreResponse holds the complete scoring result: the food item name, the breakdown of negative and positive points, the final numeric score, and the letter grade. This gives the API consumer full transparency into how the grade was determined.
- Save ScoreResponse.java.
With both DTOs in place, you can now write the service test. This is the TDD "red" phase: you write a test for code that does not exist yet.
- In the Project panel, expand src/test/java and right-click the com.example.nutriscore package.
- Select New, then Package. Type service and press Enter.
- Right-click the service package, select New, then Java Class. Name it NutriScoreServiceTest.
- Replace the entire file contents with the following test code:
package com.example.nutriscore.service;
import com.example.nutriscore.calculator.NutriScoreCalculator;
import com.example.nutriscore.config.NutriScoreProperties;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class NutriScoreServiceTest {
@Mock
private NutriScoreCalculator calculator;
private NutriScoreProperties properties;
private NutriScoreService service;
@BeforeEach
void setUp() {
// Configure grade boundaries from YAML-equivalent values
properties = new NutriScoreProperties();
NutriScoreProperties.Grades grades = new NutriScoreProperties.Grades();
grades.setAMax(-1);
grades.setBMax(2);
grades.setCMax(10);
grades.setDMax(18);
properties.setGrades(grades);
service = new NutriScoreService(calculator, properties);
}
@Test
@DisplayName("Healthy food with low negatives and high positives gets grade A")
void healthyFood_getsGradeA() {
FoodItemRequest request = new FoodItemRequest("Apple", 200.0, 0.1, 10.0, 2.0, 2.4, 0.3, 100.0);
// Mock the calculator to return controlled values
when(calculator.calculateNegativePoints(200.0, 0.1, 10.0, 2.0)).thenReturn(3);
when(calculator.calculatePositivePoints(2.4, 0.3, 100.0)).thenReturn(8);
ScoreResponse response = service.calculateScore(request);
// Final score = 3 - 8 = -5, which is <= -1, so grade A
assertEquals("A", response.getGrade());
assertEquals(-5, response.getFinalScore());
assertEquals(3, response.getNegativePoints());
assertEquals(8, response.getPositivePoints());
assertEquals("Apple", response.getName());
}
@Test
@DisplayName("Unhealthy food with high negatives and low positives gets grade E")
void unhealthyFood_getsGradeE() {
FoodItemRequest request = new FoodItemRequest("Candy Bar", 2500.0, 12.0, 50.0, 500.0, 0.5, 2.0, 0.0);
when(calculator.calculateNegativePoints(2500.0, 12.0, 50.0, 500.0)).thenReturn(30);
when(calculator.calculatePositivePoints(0.5, 2.0, 0.0)).thenReturn(1);
ScoreResponse response = service.calculateScore(request);
// Final score = 30 - 1 = 29, which is > 18, so grade E
assertEquals("E", response.getGrade());
assertEquals(29, response.getFinalScore());
}
}
What does this test do?
@ExtendWith(MockitoExtension.class) wires Mockito into JUnit 5 without spinning up the full Spring context. This keeps the test fast and isolated.
@Mock creates a fake version of NutriScoreCalculator. The when(...).thenReturn(...) calls tell the mock exactly what to return for specific inputs. This lets you test the service formula logic in isolation, without depending on the calculator's real implementation.
- Save NutriScoreServiceTest.java.
- You will see red underlines on NutriScoreService because the class does not exist yet. This is expected in TDD.
Why write a test that cannot compile?
This is the TDD "red" phase. You define the behavior you want (the test), then write the minimum code to make it pass ("green"). The test acts as a specification for how the service should behave before you decide how to build it.
Seeing errors on the Mockito imports?
Mockito is bundled with spring-boot-starter-test, which was included when you generated the project. If the import is red, try clicking the Maven reload icon in IntelliJ to re-download dependencies.
Help me fix Mockito import errors in my test class.
Implement the service to make tests pass
The service combines the calculator's output into a final score and maps it to a letter grade. It follows the Dependency Inversion principle: it depends on the calculator and properties through constructor injection, not by creating them itself.
- In the Project panel, right-click the com.example.nutriscore package under src/main/java.
- Select New, then Package. Type service and press Enter.
- Right-click the new service package, select New, then Java Class. Name it NutriScoreService.
- Replace the entire file contents with the following implementation:
package com.example.nutriscore.service;
import com.example.nutriscore.calculator.NutriScoreCalculator;
import com.example.nutriscore.config.NutriScoreProperties;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import org.springframework.stereotype.Service;
@Service
public class NutriScoreService {
private final NutriScoreCalculator calculator;
private final NutriScoreProperties properties;
// Constructor injection: Spring provides both dependencies automatically
public NutriScoreService(NutriScoreCalculator calculator, NutriScoreProperties properties) {
this.calculator = calculator;
this.properties = properties;
}
public ScoreResponse calculateScore(FoodItemRequest request) {
// Delegate point calculation to the calculator component
int negativePoints = calculator.calculateNegativePoints(
request.getEnergyKj(),
request.getSaturatedFat(),
request.getSugars(),
request.getSodium()
);
int positivePoints = calculator.calculatePositivePoints(
request.getFiber(),
request.getProtein(),
request.getFruitVegetablePercent()
);
// Core formula: subtract positive from negative
int finalScore = negativePoints - positivePoints;
String grade = determineGrade(finalScore);
return new ScoreResponse(
request.getName(),
negativePoints,
positivePoints,
finalScore,
grade
);
}
private String determineGrade(int finalScore) {
// Grade boundaries come from YAML configuration
if (finalScore <= properties.getGrades().getAMax()) {
return "A";
} else if (finalScore <= properties.getGrades().getBMax()) {
return "B";
} else if (finalScore <= properties.getGrades().getCMax()) {
return "C";
} else if (finalScore <= properties.getGrades().getDMax()) {
return "D";
} else {
return "E";
}
}
}
What does this code do?
@Service marks this class as a Spring-managed bean. Spring automatically creates an instance and injects the calculator and properties through the constructor.
The calculateScore method delegates point calculation to NutriScoreCalculator, applies the formula (negative minus positive), and determines the grade. The determineGrade method reads thresholds from YAML config so grade boundaries are easy to change without touching code.
- Save NutriScoreService.java.
- Go back to NutriScoreServiceTest.java. The red underlines on NutriScoreService should now be gone.
- Run the tests by right-clicking NutriScoreServiceTest in the editor and selecting Run 'NutriScoreServiceTest'.
- You should see 2 tests pass with green checkmarks in the Run panel at the bottom of IntelliJ.
Tests failing?
Check that your NutriScoreService constructor accepts both NutriScoreCalculator and NutriScoreProperties as parameters. The test creates the service with both in its setUp() method, so the constructor signature must match.
Help me debug my NutriScoreServiceTest failures.
Add edge case tests for grade boundaries
Two tests cover the extremes (grade A and grade E), but what about the grades in between? Boundary values are where bugs hide. A score of exactly 0 should be grade B, not A.
- In NutriScoreServiceTest.java, add the following three test methods after the existing unhealthyFood_getsGradeE method:
@Test
@DisplayName("Score of 0 gets grade B")
void scoreZero_getsGradeB() {
FoodItemRequest request = new FoodItemRequest("Bread", 1000.0, 1.5, 5.0, 400.0, 3.0, 8.0, 0.0);
when(calculator.calculateNegativePoints(1000.0, 1.5, 5.0, 400.0)).thenReturn(5);
when(calculator.calculatePositivePoints(3.0, 8.0, 0.0)).thenReturn(5);
ScoreResponse response = service.calculateScore(request);
// Final score = 5 - 5 = 0, which is <= 2, so grade B
assertEquals("B", response.getGrade());
assertEquals(0, response.getFinalScore());
}
@Test
@DisplayName("Score of 5 gets grade C")
void scoreFive_getsGradeC() {
FoodItemRequest request = new FoodItemRequest("Cheese", 1500.0, 8.0, 0.5, 600.0, 0.0, 25.0, 0.0);
when(calculator.calculateNegativePoints(1500.0, 8.0, 0.5, 600.0)).thenReturn(10);
when(calculator.calculatePositivePoints(0.0, 25.0, 0.0)).thenReturn(5);
ScoreResponse response = service.calculateScore(request);
// Final score = 10 - 5 = 5, which is <= 10, so grade C
assertEquals("C", response.getGrade());
assertEquals(5, response.getFinalScore());
}
@Test
@DisplayName("Score of 15 gets grade D")
void scoreFifteen_getsGradeD() {
FoodItemRequest request = new FoodItemRequest("Chips", 2200.0, 5.0, 1.0, 800.0, 1.0, 3.0, 0.0);
when(calculator.calculateNegativePoints(2200.0, 5.0, 1.0, 800.0)).thenReturn(18);
when(calculator.calculatePositivePoints(1.0, 3.0, 0.0)).thenReturn(3);
ScoreResponse response = service.calculateScore(request);
// Final score = 18 - 3 = 15, which is <= 18, so grade D
assertEquals("D", response.getGrade());
assertEquals(15, response.getFinalScore());
}
What do these tests verify?
Each test targets a specific grade boundary. A score of 0 falls within grade B (max 2). A score of 5 falls within grade C (max 10). A score of 15 falls within grade D (max 18). Together with the existing A and E tests, you now have coverage for all five grades.
Notice how mocking lets you pick exact point values without worrying about which food produces them. You control the inputs precisely.
- Save NutriScoreServiceTest.java.
- Right-click the file and select Run 'NutriScoreServiceTest'.
- You should see 5 tests pass with green checkmarks.
Some tests pass but others fail?
Check that your when(...) mock arguments match the exact values passed to the constructor in each FoodItemRequest. Mockito uses exact argument matching by default. If the values do not match, the mock returns 0 instead of your expected value.
Help me figure out why some of my service tests fail while others pass.
Now run all project tests together to confirm nothing is broken. Run the following command in the IntelliJ terminal:
- Run the full test suite by executing this command in the IntelliJ terminal panel at the bottom:
mvnw.cmd test
- You should see output showing all 13 tests pass (8 calculator tests + 5 service tests).
✔️ Awesome, I've got everything!
Great. Double check you have saved all four new files before moving on.
ⓧ I'd like to double check the full code
Here is the complete NutriScoreServiceTest.java file for comparison:
package com.example.nutriscore.service;
import com.example.nutriscore.calculator.NutriScoreCalculator;
import com.example.nutriscore.config.NutriScoreProperties;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class NutriScoreServiceTest {
@Mock
private NutriScoreCalculator calculator;
private NutriScoreProperties properties;
private NutriScoreService service;
@BeforeEach
void setUp() {
properties = new NutriScoreProperties();
NutriScoreProperties.Grades grades = new NutriScoreProperties.Grades();
grades.setAMax(-1);
grades.setBMax(2);
grades.setCMax(10);
grades.setDMax(18);
properties.setGrades(grades);
service = new NutriScoreService(calculator, properties);
}
@Test
@DisplayName("Healthy food with low negatives and high positives gets grade A")
void healthyFood_getsGradeA() {
FoodItemRequest request = new FoodItemRequest("Apple", 200.0, 0.1, 10.0, 2.0, 2.4, 0.3, 100.0);
when(calculator.calculateNegativePoints(200.0, 0.1, 10.0, 2.0)).thenReturn(3);
when(calculator.calculatePositivePoints(2.4, 0.3, 100.0)).thenReturn(8);
ScoreResponse response = service.calculateScore(request);
assertEquals("A", response.getGrade());
assertEquals(-5, response.getFinalScore());
assertEquals(3, response.getNegativePoints());
assertEquals(8, response.getPositivePoints());
assertEquals("Apple", response.getName());
}
@Test
@DisplayName("Unhealthy food with high negatives and low positives gets grade E")
void unhealthyFood_getsGradeE() {
FoodItemRequest request = new FoodItemRequest("Candy Bar", 2500.0, 12.0, 50.0, 500.0, 0.5, 2.0, 0.0);
when(calculator.calculateNegativePoints(2500.0, 12.0, 50.0, 500.0)).thenReturn(30);
when(calculator.calculatePositivePoints(0.5, 2.0, 0.0)).thenReturn(1);
ScoreResponse response = service.calculateScore(request);
assertEquals("E", response.getGrade());
assertEquals(29, response.getFinalScore());
}
@Test
@DisplayName("Score of 0 gets grade B")
void scoreZero_getsGradeB() {
FoodItemRequest request = new FoodItemRequest("Bread", 1000.0, 1.5, 5.0, 400.0, 3.0, 8.0, 0.0);
when(calculator.calculateNegativePoints(1000.0, 1.5, 5.0, 400.0)).thenReturn(5);
when(calculator.calculatePositivePoints(3.0, 8.0, 0.0)).thenReturn(5);
ScoreResponse response = service.calculateScore(request);
assertEquals("B", response.getGrade());
assertEquals(0, response.getFinalScore());
}
@Test
@DisplayName("Score of 5 gets grade C")
void scoreFive_getsGradeC() {
FoodItemRequest request = new FoodItemRequest("Cheese", 1500.0, 8.0, 0.5, 600.0, 0.0, 25.0, 0.0);
when(calculator.calculateNegativePoints(1500.0, 8.0, 0.5, 600.0)).thenReturn(10);
when(calculator.calculatePositivePoints(0.0, 25.0, 0.0)).thenReturn(5);
ScoreResponse response = service.calculateScore(request);
assertEquals("C", response.getGrade());
assertEquals(5, response.getFinalScore());
}
@Test
@DisplayName("Score of 15 gets grade D")
void scoreFifteen_getsGradeD() {
FoodItemRequest request = new FoodItemRequest("Chips", 2200.0, 5.0, 1.0, 800.0, 1.0, 3.0, 0.0);
when(calculator.calculateNegativePoints(2200.0, 5.0, 1.0, 800.0)).thenReturn(18);
when(calculator.calculatePositivePoints(1.0, 3.0, 0.0)).thenReturn(3);
ScoreResponse response = service.calculateScore(request);
assertEquals("D", response.getGrade());
assertEquals(15, response.getFinalScore());
}
}
Here is the complete NutriScoreService.java file:
package com.example.nutriscore.service;
import com.example.nutriscore.calculator.NutriScoreCalculator;
import com.example.nutriscore.config.NutriScoreProperties;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import org.springframework.stereotype.Service;
@Service
public class NutriScoreService {
private final NutriScoreCalculator calculator;
private final NutriScoreProperties properties;
public NutriScoreService(NutriScoreCalculator calculator, NutriScoreProperties properties) {
this.calculator = calculator;
this.properties = properties;
}
public ScoreResponse calculateScore(FoodItemRequest request) {
int negativePoints = calculator.calculateNegativePoints(
request.getEnergyKj(),
request.getSaturatedFat(),
request.getSugars(),
request.getSodium()
);
int positivePoints = calculator.calculatePositivePoints(
request.getFiber(),
request.getProtein(),
request.getFruitVegetablePercent()
);
int finalScore = negativePoints - positivePoints;
String grade = determineGrade(finalScore);
return new ScoreResponse(
request.getName(),
negativePoints,
positivePoints,
finalScore,
grade
);
}
private String determineGrade(int finalScore) {
if (finalScore <= properties.getGrades().getAMax()) {
return "A";
} else if (finalScore <= properties.getGrades().getBMax()) {
return "B";
} else if (finalScore <= properties.getGrades().getCMax()) {
return "C";
} else if (finalScore <= properties.getGrades().getDMax()) {
return "D";
} else {
return "E";
}
}
}
Here is the complete FoodItemRequest.java file:
package com.example.nutriscore.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class FoodItemRequest {
@NotNull(message = "name is required")
private String name;
@NotNull(message = "energyKj is required")
@Min(value = 0, message = "energyKj must be non-negative")
private Double energyKj;
@NotNull(message = "saturatedFat is required")
@Min(value = 0, message = "saturatedFat must be non-negative")
private Double saturatedFat;
@NotNull(message = "sugars is required")
@Min(value = 0, message = "sugars must be non-negative")
private Double sugars;
@NotNull(message = "sodium is required")
@Min(value = 0, message = "sodium must be non-negative")
private Double sodium;
@NotNull(message = "fiber is required")
@Min(value = 0, message = "fiber must be non-negative")
private Double fiber;
@NotNull(message = "protein is required")
@Min(value = 0, message = "protein must be non-negative")
private Double protein;
@NotNull(message = "fruitVegetablePercent is required")
@Min(value = 0, message = "fruitVegetablePercent must be non-negative")
private Double fruitVegetablePercent;
public FoodItemRequest() {}
public FoodItemRequest(String name, Double energyKj, Double saturatedFat, Double sugars,
Double sodium, Double fiber, Double protein, Double fruitVegetablePercent) {
this.name = name;
this.energyKj = energyKj;
this.saturatedFat = saturatedFat;
this.sugars = sugars;
this.sodium = sodium;
this.fiber = fiber;
this.protein = protein;
this.fruitVegetablePercent = fruitVegetablePercent;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Double getEnergyKj() { return energyKj; }
public void setEnergyKj(Double energyKj) { this.energyKj = energyKj; }
public Double getSaturatedFat() { return saturatedFat; }
public void setSaturatedFat(Double saturatedFat) { this.saturatedFat = saturatedFat; }
public Double getSugars() { return sugars; }
public void setSugars(Double sugars) { this.sugars = sugars; }
public Double getSodium() { return sodium; }
public void setSodium(Double sodium) { this.sodium = sodium; }
public Double getFiber() { return fiber; }
public void setFiber(Double fiber) { this.fiber = fiber; }
public Double getProtein() { return protein; }
public void setProtein(Double protein) { this.protein = protein; }
public Double getFruitVegetablePercent() { return fruitVegetablePercent; }
public void setFruitVegetablePercent(Double fruitVegetablePercent) { this.fruitVegetablePercent = fruitVegetablePercent; }
}
Here is the complete ScoreResponse.java file:
package com.example.nutriscore.dto;
public class ScoreResponse {
private String name;
private int negativePoints;
private int positivePoints;
private int finalScore;
private String grade;
public ScoreResponse() {}
public ScoreResponse(String name, int negativePoints, int positivePoints, int finalScore, String grade) {
this.name = name;
this.negativePoints = negativePoints;
this.positivePoints = positivePoints;
this.finalScore = finalScore;
this.grade = grade;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getNegativePoints() { return negativePoints; }
public void setNegativePoints(int negativePoints) { this.negativePoints = negativePoints; }
public int getPositivePoints() { return positivePoints; }
public void setPositivePoints(int positivePoints) { this.positivePoints = positivePoints; }
public int getFinalScore() { return finalScore; }
public void setFinalScore(int finalScore) { this.finalScore = finalScore; }
public String getGrade() { return grade; }
public void setGrade(String grade) { this.grade = grade; }
}
Your service layer is complete and fully tested. You have a working scoring engine that converts raw nutritional numbers into a letter grade. Next up, you will expose this logic over HTTP by building a REST controller with its own slice tests.
Create the REST Controller with Slice Tests
Your scoring algorithm works. Your service layer orchestrates it correctly. Now you need to expose that logic over HTTP so other applications can actually send food data and get a grade back.
In this step, you will write a @WebMvcTest slice test that verifies your REST endpoint without booting the full application. Then you will implement the controller and add input validation with proper error responses.
In this step, get ready to:
- Write a slice test that sends a POST request and asserts the JSON response structure.
- Implement the REST controller with a POST /api/score endpoint.
- Add tests for invalid input and implement validation error handling.
Write the controller slice test
A slice test loads only the web layer of your application. It does not start the database, configuration properties, or service beans. This makes it fast and focused on HTTP behavior alone.
What is a slice test?
Spring Boot provides test annotations that boot only a "slice" of the application context. @WebMvcTest loads controllers, filters, and converters but skips services and repositories. You mock the service layer with @MockBean so you can test HTTP mapping in isolation.
This is faster than @SpringBootTest and lets you verify request/response JSON independently of business logic.
- Create a new Java class at src/test/java/com/example/nutriscore/controller/NutriScoreControllerTest.java and paste the following test code:
package com.example.nutriscore.controller;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import com.example.nutriscore.service.NutriScoreService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(NutriScoreController.class)
class NutriScoreControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NutriScoreService nutriScoreService;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("POST /api/score with valid input returns 200 and score response")
void validInput_returns200WithScore() throws Exception {
// Arrange: create a request and the expected response
FoodItemRequest request = new FoodItemRequest("Apple", 200.0, 0.1, 10.0, 2.0, 2.4, 0.3, 100.0);
ScoreResponse response = new ScoreResponse("Apple", 3, 8, -5, "A");
when(nutriScoreService.calculateScore(any(FoodItemRequest.class))).thenReturn(response);
// Act & Assert: send POST and verify JSON fields
mockMvc.perform(post("/api/score")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Apple"))
.andExpect(jsonPath("$.grade").value("A"))
.andExpect(jsonPath("$.finalScore").value(-5))
.andExpect(jsonPath("$.negativePoints").value(3))
.andExpect(jsonPath("$.positivePoints").value(8));
}
}
What does this test do?
@WebMvcTest(NutriScoreController.class) tells Spring to load only the controller slice. MockMvc simulates HTTP requests without starting a real server.
@MockBean replaces the real NutriScoreService with a Mockito mock. The test verifies that sending valid JSON to POST /api/score returns a 200 status with the expected JSON fields.
What about @MockitoBean?
In Spring Boot 3.4+, @MockBean is technically deprecated in favor of @MockitoBean. Both work identically for this project. We use @MockBean because most tutorials and real codebases still reference it.
- Run this test by right-clicking the test class in IntelliJ IDEA and selecting Run 'NutriScoreControllerTest'.
The test fails because NutriScoreController does not exist yet. This is the TDD red phase. Now you will write the implementation to make it pass.
Implement the REST controller
The controller is the entry point for HTTP traffic. It receives JSON, delegates to the service, and returns the result.
- Create a new Java class at src/main/java/com/example/nutriscore/controller/NutriScoreController.java and paste the following code:
package com.example.nutriscore.controller;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import com.example.nutriscore.service.NutriScoreService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// Marks this class as a REST controller that returns JSON
@RestController
@RequestMapping("/api")
public class NutriScoreController {
private final NutriScoreService nutriScoreService;
// Constructor injection (Dependency Inversion principle)
public NutriScoreController(NutriScoreService nutriScoreService) {
this.nutriScoreService = nutriScoreService;
}
// POST /api/score - accepts food data, returns score breakdown
@PostMapping("/score")
public ResponseEntity<ScoreResponse> calculateScore(@Valid @RequestBody FoodItemRequest request) {
ScoreResponse response = nutriScoreService.calculateScore(request);
return ResponseEntity.ok(response);
}
}
What does this code do?
@RestController tells Spring this class handles HTTP requests and returns JSON. @RequestMapping("/api") sets the base path for all endpoints in this controller.
@Valid activates the validation annotations you already added to FoodItemRequest (like @NotNull and @Min). Without @Valid, Spring ignores those constraints entirely.
- Save NutriScoreController.java.
- Re-run NutriScoreControllerTest by right-clicking it and selecting Run.
You should see the validInput_returns200WithScore test pass with a green checkmark.
Test still failing?
Make sure the package declaration matches: com.example.nutriscore.controller. The test's @WebMvcTest(NutriScoreController.class) must be able to find the controller in the same package.
Also confirm the class name is exactly NutriScoreController with no typos.
Why is my controller slice test failing?
Add validation tests and error handling
A production API must reject bad input gracefully. Right now, if someone sends a request with missing fields or negative numbers, Spring throws an unformatted error. You need a test that defines what the error response should look like, then an implementation that produces it.
- In NutriScoreControllerTest.java, add these two test methods below the existing validInput_returns200WithScore method:
@Test
@DisplayName("POST /api/score with missing fields returns 400")
void missingFields_returns400() throws Exception {
// Only send a name, missing all numeric fields
String invalidJson = "{\"name\": \"Apple\"}";
mockMvc.perform(post("/api/score")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value("error"))
.andExpect(jsonPath("$.errors").isNotEmpty());
}
@Test
@DisplayName("POST /api/score with negative values returns 400")
void negativeValues_returns400() throws Exception {
// Send a negative energyKj value to trigger @Min validation
FoodItemRequest request = new FoodItemRequest("Bad", -100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
mockMvc.perform(post("/api/score")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.energyKj").exists());
}
What do these tests verify?
The first test sends JSON with only a name field. Since the other fields have @NotNull annotations, Spring should reject the request with a 400 status and a structured error response.
The second test sends a negative energy value. The @Min(0) annotation on energyKj should catch this and return a 400 with the specific field error.
- Save NutriScoreControllerTest.java and run the full test class.
The two new tests fail. Spring returns a default error format that does not match your expected JSON structure. You need a global exception handler to shape the error response.
- Create a new Java class at src/main/java/com/example/nutriscore/exception/GlobalExceptionHandler.java and paste the following code:
package com.example.nutriscore.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
// Catches validation errors globally and formats the response
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, Object> errors = new HashMap<>();
errors.put("status", "error");
// Collect each field's error message into a map
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
errors.put("errors", fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}
What does this code do?
@RestControllerAdvice intercepts exceptions thrown by any controller. When @Valid rejects a request, Spring throws a MethodArgumentNotValidException. This handler catches it and builds a clean JSON response with a status field and an errors map of field names to error messages.
- Save GlobalExceptionHandler.java.
- Re-run the full NutriScoreControllerTest class.
All three tests should pass with green checkmarks.
Validation tests still returning 200 instead of 400?
Check that your controller method has @Valid before @RequestBody. Without @Valid, Spring skips validation entirely and passes the request through.
Also confirm that spring-boot-starter-validation is in your pom.xml dependencies. The jakarta.validation annotations need this dependency to activate at runtime.
Why are my validation annotations not being enforced in my Spring Boot controller?
- Run the full test suite to confirm nothing is broken by running this command in your terminal:
mvnw.cmd test
You should see all 17 tests pass (8 calculator + 5 service + 3 controller + 1 context).
Seeing compilation errors across multiple test files?
If mvnw.cmd test shows import errors, make sure your packages match the folder structure exactly. Java requires com.example.nutriscore.controller to live in src/main/java/com/example/nutriscore/controller/.
My Maven build is failing with compilation errors across multiple test files. How do I fix package and import issues?
✔️ Awesome, I've got everything!
Great. Double check you have saved all three new files before committing.
ⓧ I'd like to double check the full code
Here is the complete controller test file:
package com.example.nutriscore.controller;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import com.example.nutriscore.service.NutriScoreService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(NutriScoreController.class)
class NutriScoreControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NutriScoreService nutriScoreService;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("POST /api/score with valid input returns 200 and score response")
void validInput_returns200WithScore() throws Exception {
FoodItemRequest request = new FoodItemRequest("Apple", 200.0, 0.1, 10.0, 2.0, 2.4, 0.3, 100.0);
ScoreResponse response = new ScoreResponse("Apple", 3, 8, -5, "A");
when(nutriScoreService.calculateScore(any(FoodItemRequest.class))).thenReturn(response);
mockMvc.perform(post("/api/score")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Apple"))
.andExpect(jsonPath("$.grade").value("A"))
.andExpect(jsonPath("$.finalScore").value(-5))
.andExpect(jsonPath("$.negativePoints").value(3))
.andExpect(jsonPath("$.positivePoints").value(8));
}
@Test
@DisplayName("POST /api/score with missing fields returns 400")
void missingFields_returns400() throws Exception {
String invalidJson = "{\"name\": \"Apple\"}";
mockMvc.perform(post("/api/score")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value("error"))
.andExpect(jsonPath("$.errors").isNotEmpty());
}
@Test
@DisplayName("POST /api/score with negative values returns 400")
void negativeValues_returns400() throws Exception {
FoodItemRequest request = new FoodItemRequest("Bad", -100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
mockMvc.perform(post("/api/score")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.energyKj").exists());
}
}
Here is the complete controller file:
package com.example.nutriscore.controller;
import com.example.nutriscore.dto.FoodItemRequest;
import com.example.nutriscore.dto.ScoreResponse;
import com.example.nutriscore.service.NutriScoreService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class NutriScoreController {
private final NutriScoreService nutriScoreService;
public NutriScoreController(NutriScoreService nutriScoreService) {
this.nutriScoreService = nutriScoreService;
}
@PostMapping("/score")
public ResponseEntity<ScoreResponse> calculateScore(@Valid @RequestBody FoodItemRequest request) {
ScoreResponse response = nutriScoreService.calculateScore(request);
return ResponseEntity.ok(response);
}
}
Here is the complete exception handler file:
package com.example.nutriscore.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, Object> errors = new HashMap<>();
errors.put("status", "error");
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
errors.put("errors", fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}
- Commit your work by running these commands:
git add .
git commit -m "Add REST controller with slice tests and validation"
Your API is now fully functional. You have a tested controller that accepts food data, validates inputs, and returns a Nutri-Score grade. The TDD cycle is complete across all three layers: calculator, service, and controller.
Secret mission
Add a Comparison Endpoint
Your API can score a single food item. But what if a user wants to know which of two items is healthier and why? Build a /api/compare endpoint that accepts two food items and returns a detailed side-by-side breakdown.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources on disk, stop any running processes, or delete the project entirely.
Resources you used:
- Spring Boot project folder (generated from start.spring.io).
- Local Git repository with commit history.
- Java source files (controller, service, calculator, config, DTOs, tests).
- Maven wrapper and downloaded dependencies (in the .m2 cache folder).
Keep everything running
No action needed. Choose this if you plan to continue building on this project or want to reference it later.
- Your project files stay on your Desktop.
- Your Git history is preserved for future reference.
- If the Spring Boot server is still running in your terminal, you can stop it with Ctrl+C when you are done testing.
Pause - I'll come back to this later
Stop any running processes to free up system memory. Your files stay on disk.
- If the Spring Boot server is running, press Ctrl+C in the terminal to stop it.
- Close IntelliJ IDEA if you are done for now.
- Your project folder and Git history remain intact for when you return.
Delete - I don't want to use this again
Remove all project files from your machine. You can always regenerate the project from start.spring.io if you change your mind.
- Stop the Spring Boot server if it is still running (Ctrl+C in the terminal).
- Delete the project folder from your Desktop.
- Optionally, clear the Maven dependency cache by deleting the .m2/repository folder in your user home directory. This frees up disk space but means Maven will re-download dependencies for future projects.
Nice Work!
Nice Work!
You just built a real scoring engine, test-first, with clean architecture. That is exactly the daily rhythm of a professional Java developer working with Spring Boot in production.
You've learned how to:
- Build a Spring Boot REST API from scratch with Maven and Java 17, using a layered architecture with separate Controller, Service, and Calculator components.
- Practice Test-Driven Development by writing failing tests first with JUnit 5 and Mockito, then implementing code to make them pass.
- Apply SOLID principles (Single Responsibility, Dependency Inversion) and externalize scoring thresholds in YAML configuration using @ConfigurationProperties.
- Secret Mission: Added a comparison endpoint that accepts two food items and returns a detailed breakdown of which is healthier and why.
Ready to quiz yourself?