Duration: 30-40 minutes
Learning Objectives:
- Master the Red-Green-Refactor TDD cycle with AI assistance
- Use Copilot to generate tests before implementation
- Apply repository Copilot Instructions for consistent code quality
- Understand how TDD enforces better design decisions
- Compare TDD patterns across .NET and Spring Boot
Choose Your Path: This lab supports both .NET and Spring Boot implementations.
- For .NET: Follow sections marked with 🔷 or "(.NET)"
- For Spring Boot: Follow sections marked with 🟩 or "(Spring Boot)"
- Mixed Groups: Facilitators can demonstrate both approaches side-by-side
The TDD principles and workflow are identical across both stacks—only syntax and frameworks differ.
In this lab, you'll create a NotificationService that sends task notifications via email and SMS. You'll follow strict Test-Driven Development (TDD) practices:
- Design - Create the interface first
- Red - Write failing tests
- Green - Implement code to pass tests
- Refactor - Improve and reflect
Why TDD? Writing tests first forces you to think about your API design, ensures testability, and provides living documentation of behavior. This principle applies equally to .NET and Spring Boot development.
- ✅ Repository cloned and
mainbranch checked out - ✅ VS Code open with GitHub Copilot enabled
- ✅
.github/instructions/dotnet.instructions.mdand.github/instructions/csharp.instructions.mdexist - ✅ Initial build successful:
dotnet build && dotnet test - ✅ DevContainer: Use
.devcontainer/dotnet-participantor.devcontainer/maintainer
- ✅ Repository cloned and
mainbranch checked out - ✅ VS Code open with GitHub Copilot enabled
- ✅
.github/instructions/springboot.instructions.mdexists - ✅ Java 21 and Maven 3.9+ installed
- ✅ Initial build successful:
cd src-springboot && mvn clean test - ✅ DevContainer: Use
.devcontainer/springboot-participantor.devcontainer/maintainer
Goal: Define the API before writing any tests or implementation. This forces you to think about what clients of your service will need.
- Press
Ctrl+Alt+I(Windows/Linux) orCmd+Shift+I(Mac) - This opens the Copilot Chat panel
In the chat panel, enter:
Create an INotificationService interface in the Application layer for sending email and SMS notifications about tasks. Include methods for both individual and combined notifications.
In the chat panel, enter:
Create a NotificationService interface in the application layer for sending email and SMS notifications about tasks. Use Java naming conventions and CompletableFuture for async operations.
Copilot should generate something like:
namespace TaskManager.Application.Services;
public interface INotificationService
{
Task SendEmailNotificationAsync(string recipient, string subject, string message, CancellationToken cancellationToken = default);
Task SendSmsNotificationAsync(string phoneNumber, string message, CancellationToken cancellationToken = default);
Task SendNotificationAsync(string recipient, string phoneNumber, string subject, string message, CancellationToken cancellationToken = default);
}Expected Location: src/TaskManager.Application/Services/INotificationService.cs
Copilot should generate something like:
package com.example.taskmanager.application.services;
/**
* Service interface for sending task notifications via multiple channels.
* Follows Clean Architecture principles - defined in application layer,
* implemented by infrastructure adapters.
*/
public interface NotificationService {
/**
* Send email notification.
*
* @param recipient email address
* @param subject email subject
* @param message email body
*/
void sendEmailNotification(String recipient, String subject, String message);
/**
* Send SMS notification.
*
* @param phoneNumber recipient phone number
* @param message SMS message body
*/
void sendSmsNotification(String phoneNumber, String message);
/**
* Send both email and SMS notification.
*
* @param recipient email address
* @param phoneNumber phone number
* @param subject email subject
* @param message notification message
*/
void sendNotification(String recipient, String phoneNumber, String subject, String message);
}Expected Location: src-springboot/taskmanager-application/src/main/java/com/example/taskmanager/application/services/NotificationService.java
Note: Spring Boot applications typically use synchronous methods—Spring manages threading internally. If async is needed, use
CompletableFuture<Void>return types.
Review the interface and ask yourself:
- ✅ Does it belong in the Application layer?
- Yes - it's a service interface (port in Clean Architecture)
- ✅ Are method names descriptive and intention-revealing?
- .NET: Uses
Asyncsuffix following C# conventions - Spring Boot: Uses camelCase following Java conventions
- .NET: Uses
- ✅ Does it follow framework conventions?
- .NET:
async/awaitwithCancellationToken - Spring Boot: Synchronous methods (Spring handles threading)
- .NET:
- ✅ Is the API easy to use and understand?
If satisfied, accept the code. If not, refine your prompt.
Pattern Reference: See Pattern Translation Guide for more interface comparisons.
Critical TDD Principle: Write tests BEFORE implementation. This is the "Red" phase - tests will fail because the implementation doesn't exist yet.
In Copilot Chat, enter:
Create xUnit tests for NotificationService in the pattern specified in our .NET instructions. Organize tests by method with separate test classes. Use FakeItEasy for mocking ILogger. Test happy path and all guard clauses.
In Copilot Chat, enter:
Create JUnit 5 tests for NotificationServiceImpl in the pattern specified in our Spring Boot instructions. Organize tests with @Nested classes for each method. Use Mockito for mocking. Test happy path and all guard clauses. Use @DisplayName for readable test names.
Copilot should create a folder structure like:
tests/TaskManager.UnitTests/Services/NotificationServiceTests/
├── SendEmailNotificationAsyncTests.cs
├── SendSmsNotificationAsyncTests.cs
└── SendNotificationAsyncTests.cs
Example test class (SendEmailNotificationAsyncTests.cs):
using Xunit;
using FakeItEasy;
using Microsoft.Extensions.Logging;
using TaskManager.Application.Services;
namespace TaskManager.UnitTests.Services.NotificationServiceTests;
public sealed class SendEmailNotificationAsyncTests
{
private readonly ILogger<NotificationService> _fakeLogger;
public SendEmailNotificationAsyncTests()
{
_fakeLogger = A.Fake<ILogger<NotificationService>>();
}
[Fact]
public async Task SendEmailNotificationAsync_WithValidParams_SendsEmail()
{
// Arrange
var service = new NotificationService(_fakeLogger);
// Act
await service.SendEmailNotificationAsync(
"test@example.com",
"Test Subject",
"Test Message");
// Assert
// Will verify logging once implementation exists
}
[Theory]
[InlineData(null, "subject", "message")]
[InlineData("", "subject", "message")]
[InlineData(" ", "subject", "message")]
public async Task SendEmailNotificationAsync_WithInvalidRecipient_ThrowsArgumentException(
string recipient, string subject, string message)
{
// Arrange
var service = new NotificationService(_fakeLogger);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => service.SendEmailNotificationAsync(recipient, subject, message));
}
// Additional guard clause tests...
}Copilot should create test classes in:
src-springboot/taskmanager-application/src/test/java/
com/example/taskmanager/application/services/
NotificationServiceImplTest.java
Example test class:
package com.example.taskmanager.application.services;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.Logger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("NotificationService Tests")
class NotificationServiceImplTest {
@Mock
private Logger logger;
private NotificationServiceImpl service;
@BeforeEach
void setUp() {
service = new NotificationServiceImpl(logger);
}
@Nested
@DisplayName("sendEmailNotification()")
class SendEmailNotificationTests {
@Test
@DisplayName("should send email with valid parameters")
void shouldSendEmailWithValidParams() {
// Arrange
String recipient = "test@example.com";
String subject = "Test Subject";
String message = "Test Message";
// Act
assertDoesNotThrow(() ->
service.sendEmailNotification(recipient, subject, message)
);
// Assert
verify(logger, times(2)).info(anyString(), any());
}
@Test
@DisplayName("should throw exception when recipient is null")
void shouldThrowWhenRecipientIsNull() {
// Act & Assert
assertThrows(IllegalArgumentException.class, () ->
service.sendEmailNotification(null, "subject", "message")
);
}
@Test
@DisplayName("should throw exception when recipient is empty")
void shouldThrowWhenRecipientIsEmpty() {
// Act & Assert
assertThrows(IllegalArgumentException.class, () ->
service.sendEmailNotification("", "subject", "message")
);
}
@Test
@DisplayName("should throw exception when recipient is blank")
void shouldThrowWhenRecipientIsBlank() {
// Act & Assert
assertThrows(IllegalArgumentException.class, () ->
service.sendEmailNotification(" ", "subject", "message")
);
}
// Additional guard clause tests for subject and message...
}
@Nested
@DisplayName("sendSmsNotification()")
class SendSmsNotificationTests {
// Similar structure for SMS tests...
}
@Nested
@DisplayName("sendNotification()")
class SendNotificationTests {
// Combined notification tests...
}
}| Aspect | .NET (xUnit + FakeItEasy) | Spring Boot (JUnit 5 + Mockito) |
|---|---|---|
| Test Attribute | [Fact] |
@Test |
| Parameterized | [Theory] + [InlineData] |
@ParameterizedTest + @ValueSource |
| Test Organization | Separate class files | @Nested classes in one file |
| Mocking | A.Fake<T>() |
@Mock annotation |
| Setup | Constructor injection | @BeforeEach method |
| Assertions | Assert.ThrowsAsync<T>() |
assertThrows() |
| Verification | A.CallTo().MustHaveHappened() |
verify(mock, times(n)).method() |
| Display Names | Method name | @DisplayName annotation |
Pattern Reference: See [Testing Patterns](../guides/dotnet-to-springboot-p atterns.md#testing-patterns) for detailed comparisons.
dotnet testExpected Result: ❌ Tests FAIL
You should see errors like:
error CS0246: The type or namespace name 'NotificationService' could not be found
cd src-springboot
mvn test -Dtest=NotificationServiceImplTestExpected Result: ❌ Tests FAIL
You should see compilation errors like:
[ERROR] cannot find symbol
[ERROR] symbol: class NotificationServiceImpl
[ERROR] location: package com.example.taskmanager.application.services
This is GOOD! You're in the "Red" phase of TDD. The tests define what you need to build.
Before implementing, review:
- ✅ Do test names clearly describe behavior?
- .NET: Method names are the description
- Spring Boot:
@DisplayNameprovides readable descriptions
- ✅ Are guard clause tests comprehensive?
- Both stacks test null, empty, and whitespace
- ✅ Is the happy path covered?
- Both have positive test cases
- ✅ Are tests organized logically?
- .NET: Multiple test class files
- Spring Boot:
@Nestedclasses in single file ├── SendSmsNotificationAsyncTests.cs └── SendNotificationAsyncTests.cs
Each test class should contain:
- ✅ Tests for the happy path (valid inputs)
- ✅ Tests for guard clauses (null/empty parameters)
- ✅ Descriptive test method names (e.g., `SendEmailNotificationAsync_WithValidInputs_SendsEmail`)
- ✅ FakeItEasy mocks for `ILogger<NotificationService>`
- ✅ Async test methods with proper assertions
### 2.3 Example Test (SendEmailNotificationAsyncTests.cs)
```csharp
namespace TaskManager.UnitTests.Services.NotificationServiceTests;
public sealed class SendEmailNotificationAsyncTests
{
private readonly ILogger<NotificationService> _logger;
private readonly NotificationService _sut;
public SendEmailNotificationAsyncTests()
{
_logger = A.Fake<ILogger<NotificationService>>();
_sut = new NotificationService(_logger);
}
[Fact]
public async Task SendEmailNotificationAsync_WithValidInputs_SendsEmail()
{
// Arrange
const string recipient = "user@example.com";
const string subject = "Task Update";
const string message = "Your task has been updated";
// Act
await _sut.SendEmailNotificationAsync(recipient, subject, message);
// Assert
// Verify logging occurred (implementation detail we'll check)
A.CallTo(_logger).Where(call =>
call.Method.Name == "Log" &&
call.GetArgument<LogLevel>(0) == LogLevel.Information)
.MustHaveHappened();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task SendEmailNotificationAsync_WithInvalidRecipient_ThrowsArgumentException(string invalidRecipient)
{
// Arrange
const string subject = "Test";
const string message = "Test message";
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() =>
_sut.SendEmailNotificationAsync(invalidRecipient, subject, message));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task SendEmailNotificationAsync_WithInvalidSubject_ThrowsArgumentException(string invalidSubject)
{
// Arrange
const string recipient = "user@example.com";
const string message = "Test message";
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() =>
_sut.SendEmailNotificationAsync(recipient, invalidSubject, message));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task SendEmailNotificationAsync_WithInvalidMessage_ThrowsArgumentException(string invalidMessage)
{
// Arrange
const string recipient = "user@example.com";
const string subject = "Test";
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() =>
_sut.SendEmailNotificationAsync(recipient, subject, invalidMessage));
}
}
In the terminal, run:
dotnet testExpected Result: ❌ Tests FAIL
You should see errors like:
error CS0246: The type or namespace name 'NotificationService' could not be found
This is GOOD! You're in the "Red" phase of TDD. The tests define what you need to build.
Before implementing, review:
- ✅ Do test names clearly describe behavior?
- ✅ Are guard clause tests comprehensive?
- ✅ Is the happy path covered?
- ✅ Are tests organized by method?
Goal: Write the MINIMUM code needed to make tests pass. No more, no less.
In Copilot Chat, enter:
Implement NotificationService that passes all the tests. Follow our .NET coding style: sealed class, file-scoped namespace, ILogger dependency injection, async/await, guard clauses with nameof.
In Copilot Chat, enter:
Implement NotificationServiceImpl that passes all the tests. Follow our Spring Boot coding style: @Service annotation, constructor injection with @RequiredArgsConstructor, SLF4J logging, guard clauses with IllegalArgumentException, JavaDoc comments.
Copilot should generate src/TaskManager.Application/Services/NotificationService.cs:
namespace TaskManager.Application.Services;
public sealed class NotificationService : INotificationService
{
private readonly ILogger<NotificationService> _logger;
public NotificationService(ILogger<NotificationService> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SendEmailNotificationAsync(
string recipient,
string subject,
string message,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(recipient))
throw new ArgumentException("Recipient cannot be null or empty", nameof(recipient));
if (string.IsNullOrWhiteSpace(subject))
throw new ArgumentException("Subject cannot be null or empty", nameof(subject));
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Message cannot be null or empty", nameof(message));
_logger.LogInformation(
"Sending email notification to {Recipient} with subject {Subject}",
recipient,
subject);
// Simulate email sending
await Task.Delay(100, cancellationToken);
_logger.LogInformation(
"Email notification sent successfully to {Recipient}",
recipient);
}
public async Task SendSmsNotificationAsync(
string phoneNumber,
string message,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(phoneNumber))
throw new ArgumentException("Phone number cannot be null or empty", nameof(phoneNumber));
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Message cannot be null or empty", nameof(message));
_logger.LogInformation(
"Sending SMS notification to {PhoneNumber}",
phoneNumber);
// Simulate SMS sending
await Task.Delay(100, cancellationToken);
_logger.LogInformation(
"SMS notification sent successfully to {PhoneNumber}",
phoneNumber);
}
public async Task SendNotificationAsync(
string recipient,
string phoneNumber,
string subject,
string message,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(recipient))
throw new ArgumentException("Recipient cannot be null or empty", nameof(recipient));
if (string.IsNullOrWhiteSpace(phoneNumber))
throw new ArgumentException("Phone number cannot be null or empty", nameof(phoneNumber));
if (string.IsNullOrWhiteSpace(subject))
throw new ArgumentException("Subject cannot be null or empty", nameof(subject));
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Message cannot be null or empty", nameof(message));
_logger.LogInformation(
"Sending combined notification to email {Recipient} and phone {PhoneNumber}",
recipient,
phoneNumber);
await SendEmailNotificationAsync(recipient, subject, message, cancellationToken);
await SendSmsNotificationAsync(phoneNumber, message, cancellationToken);
_logger.LogInformation(
"Combined notification sent successfully");
}
}Copilot should generate src-springboot/taskmanager-application/src/main/java/com/example/taskmanager/application/services/NotificationServiceImpl.java:
package com.example.taskmanager.application.services;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Implementation of NotificationService.
* Sends task notifications via email and SMS.
*
* Note: This is a simplified implementation for testing purposes.
* In production, would integrate with actual email and SMS providers.
*/
@Service
@RequiredArgsConstructor
public class NotificationServiceImpl implements NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationServiceImpl.class);
@Override
public void sendEmailNotification(String recipient, String subject, String message) {
validateParameter(recipient, "Recipient");
validateParameter(subject, "Subject");
validateParameter(message, "Message");
log.info("Sending email notification to {} with subject {}", recipient, subject);
// Simulate email sending
simulateDelay(100);
log.info("Email notification sent successfully to {}", recipient);
}
@Override
public void sendSmsNotification(String phoneNumber, String message) {
validateParameter(phoneNumber, "Phone number");
validateParameter(message, "Message");
log.info("Sending SMS notification to {}", phoneNumber);
// Simulate SMS sending
simulateDelay(100);
log.info("SMS notification sent successfully to {}", phoneNumber);
}
@Override
public void sendNotification(String recipient, String phoneNumber, String subject, String message) {
validateParameter(recipient, "Recipient");
validateParameter(phoneNumber, "Phone number");
validateParameter(subject, "Subject");
validateParameter(message, "Message");
log.info("Sending combined notification to email {} and phone {}", recipient, phoneNumber);
sendEmailNotification(recipient, subject, message);
sendSmsNotification(phoneNumber, message);
log.info("Combined notification sent successfully");
}
/**
* Validate that a parameter is not null or blank.
*
* @param value the value to validate
* @param parameterName the parameter name for the exception message
* @throws IllegalArgumentException if the value is null or blank
*/
private void validateParameter(String value, String parameterName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(parameterName + " cannot be null or empty");
}
}
/**
* Simulate async operation delay.
*/
private void simulateDelay(int milliseconds) {
try {
Thread.sleep(milliseconds);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Simulation interrupted", e);
}
}
}Key Spring Boot Patterns:
@Serviceannotation registers in Spring IoC container@RequiredArgsConstructor(Lombok) generates constructor- Static
Loggerinstance (common Java pattern) IllegalArgumentExceptionfor validation (Java standard)- Helper method
validateParameter()to reduce duplication Thread.sleep()for simulation (notasync/await)
Check that the implementation follows all conventions:
- ✅
sealed class- Class cannot be inherited (defensive design) - ✅ File-scoped namespace -
namespace TaskManager.Application.Services; - ✅ Constructor validation -
logger ?? throw new ArgumentNullException(nameof(logger)) - ✅ Guard clauses - All parameters validated at method start
- ✅
nameof()operator - Used in all exceptions for refactoring safety - ✅ Async/await - All methods properly async with
CancellationToken - ✅ Structured logging - Parameters passed to logger, not string interpolation
- ✅ No
elsestatements - Guard clauses enable "fail fast" pattern - ✅ Single responsibility - Class only handles notifications
Check that the implementation follows all conventions:
- ✅
@Serviceannotation - Marks as Spring service component - ✅ Interface implementation -
implements NotificationService - ✅ Lombok annotations -
@Slf4jfor logging,@RequiredArgsConstructorfor DI - ✅ Guard clauses - All parameters validated at method start with
IllegalArgumentException - ✅ SLF4J structured logging - Uses parameterized logging:
log.info("...", param1, param2) - ✅ No constructor needed - Lombok generates it for
finalfields (if any dependencies added) - ✅ Single responsibility - Class only handles notifications
- ✅ Exception messages - Clear, descriptive error messages
In the terminal, run:
dotnet testExpected Result: ✅ Tests PASS
You should see:
Passed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12
In the terminal, run:
mvn testor
./mvnw test # If using Maven wrapperExpected Result: ✅ Tests PASS
You should see:
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Congratulations! You've completed the Red-Green cycle.
Goal: Improve code quality without changing behavior. Tests should still pass.
Ask yourself:
- ✅ Layer Separation: Is
NotificationServicecorrectly in the Application layer?- Yes - it's a use case/service, not domain logic or infrastructure
- ✅ Dependencies: Does it only depend on
ILogger(infrastructure concern)?- Yes - clean dependency injection
- ✅ Domain Logic: Is there any domain logic here?
- No - this is pure application service orchestration
Ask yourself:
- ✅ Layer Separation: Is
NotificationServiceImplcorrectly in theapplication.servicespackage?- Yes - it's a use case/service, not domain logic or infrastructure
- ✅ Dependencies: Does it only depend on
Logger(crosscutting concern)?- Yes - clean separation (no infrastructure dependencies yet)
- ✅ Domain Logic: Is there any domain logic here?
- No - this is pure application service orchestration
- ✅ Spring Best Practices: Using
@Servicefor component scanning?- Yes - proper Spring stereotype annotation
Ask yourself:
- ✅ Test Organization: Are tests organized by method (nested test classes)?
- ✅ Descriptive Names: Can you understand behavior just by reading test names?
- ✅ Test Coverage: Are all edge cases covered (null, empty, whitespace)?
- ✅ Test Independence: Does each test run independently?
- ✅ Mocking Strategy: Are mocks used appropriately (only for dependencies)?
Reusable Prompt (works for both stacks):
Use the
/checkslash command in Copilot Chat to get code review and improvement suggestions:/check Review the NotificationService implementation and tests. Are there any improvements we could make while keeping the same behavior?
Copilot might suggest:
- Extract validation logic into a helper method (reduce duplication)
- Add more specific exception types (e.g.,
InvalidEmailException) - Add integration tests for actual email/SMS providers
- Add telemetry/tracing with OpenTelemetry (workshop bonus!)
- Spring Boot specific: Consider using Spring Validation annotations (
@NotBlank,@Email)
If time permits, try extracting parameter validation:
private static void ValidateParameter(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} cannot be null or empty", parameterName);
}Then refactor methods to use:
ValidateParameter(recipient, nameof(recipient));
ValidateParameter(subject, nameof(subject));
ValidateParameter(message, nameof(message));Run tests again: dotnet test - Should still pass! ✅
If time permits, try extracting parameter validation:
private void validateParameter(String value, String parameterName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(parameterName + " cannot be null or blank");
}
}Then refactor methods to use:
validateParameter(recipient, "recipient");
validateParameter(subject, "subject");
validateParameter(message, "message");Run tests again: mvn test - Should still pass! ✅
- Design First: Interface and tests forced you to think about the API before writing code
- Clear Requirements: Tests document exactly what the service should do
- Confidence: Every change is validated by tests
- Refactoring Safety: Can improve code structure without fear of breaking behavior
- No Overengineering: Only wrote code needed to pass tests
- Consistency: All generated code follows the same conventions
- Quality: Guard clauses, async/await, structured logging automatically included
- Best Practices: Sealed classes,
nameof(), file-scoped namespaces enforced - Test Patterns: xUnit + FakeItEasy patterns consistently applied
- Consistency: All generated code follows Spring Boot conventions
- Quality: Guard clauses, exception handling, SLF4J logging automatically included
- Best Practices:
@Serviceannotations, Lombok annotations, proper interface implementation - Test Patterns: JUnit 5 + Mockito patterns consistently applied
- ❌ Writing implementation before tests - You lose design feedback
- ❌ Writing tests after implementation - Tests tend to just verify existing code, not drive design
- ❌ Skipping the "Red" phase - You don't know if tests actually test anything
- ❌ Making tests pass by changing tests - Tests define requirements; don't cheat!
- ❌ Ignoring failing tests - Red → Green → Refactor, always in that order
- Write a test that verifies email format validation
- Implement email validation in
SendEmailNotificationAsync - Consider using
System.ComponentModel.DataAnnotations.EmailAddressAttribute - Ensure tests pass
- Write a test that verifies email format validation
- Implement email validation in
sendEmail - Consider using Spring's
@Emailvalidation or Apache Commons Validator - Ensure tests pass
- Research OpenTelemetry in the workshop instructions
- Add
ActivitySourcetracing to notification methods - Write tests that verify activities are created
- Research OpenTelemetry in the workshop instructions
- Add
@WithSpanannotations or manual span creation - Write tests that verify spans are created
- Design an interface method:
Task SendBatchNotificationsAsync(IEnumerable<Notification> notifications) - Write tests for batch sending (multiple recipients)
- Implement batch notification logic
- Design an interface method:
void sendBatchNotifications(List<Notification> notifications) - Write tests for batch sending (multiple recipients)
- Implement batch notification logic
You've completed this lab successfully when:
- ✅
INotificationServiceinterface created in Application layer - ✅ Test suite created with 12+ passing tests using xUnit + FakeItEasy
- ✅
NotificationServiceimplementation follows all Copilot Instructions conventions - ✅ You followed Red-Green-Refactor cycle (saw tests fail, then pass)
- ✅ Code uses: sealed classes, file-scoped namespaces, guard clauses,
nameof() - ✅ Tests run successfully with
dotnet test
- ✅
NotificationServiceinterface created inapplication.servicespackage - ✅ Test suite created with 12+ passing tests using JUnit 5 + Mockito
- ✅
NotificationServiceImplfollows Spring Boot best practices - ✅ You followed Red-Green-Refactor cycle (saw tests fail, then pass)
- ✅ Code uses:
@Service, Lombok annotations, guard clauses, SLF4J logging - ✅ Tests run successfully with
mvn test
- ✅ You understand why TDD leads to better design
- ✅ Code is clean, readable, and well-organized
- ✅ You can explain the Red-Green-Refactor cycle
Problem: NotificationService type not found
Solution: This is expected in the Red phase! Implement the service in Step 3.
Problem: Tests pass even though no implementation exists
Solution: Your tests might be too lenient. Review test assertions.
Problem: Generated code doesn't use sealed classes, nameof, etc.
Solution:
- Verify
.github/instructions/directory exists with instruction files - Reload VS Code window:
F1→ "Developer: Reload Window" - Be explicit in prompts: "Follow .NET conventions"
Problem: Can't create fakes or verify calls
Solution:
- Ensure using directive:
using FakeItEasy; - Check NuGet package is installed in test project
- Review FakeItEasy syntax in existing tests
Problem: NotificationServiceImpl symbol not found
Solution: This is expected in the Red phase! Implement the service in Step 3.
Problem: @Mock or @InjectMocks not injecting correctly
Solution:
- Ensure
@ExtendWith(MockitoExtension.class)on test class - Check Mockito dependency in
pom.xml(should be in parent) - Use
@BeforeEachto manually initialize mocks if needed
Problem: Logger is null in service
Solution:
- Ensure SLF4J dependency is included
- Use static logger:
private static final Logger log = LoggerFactory.getLogger(ClassName.class); - Or inject with constructor if using a logging facade
Problem: mvn test fails with compilation errors
Solution:
- Run
mvn cleanfirst to clear old builds - Verify Java 21 is active:
java -version - Check all imports are correct (use VS Code auto-import:
Ctrl+.) - Ensure Lombok plugin is installed in VS Code
Problem: Context fails to load during tests
Solution:
- This lab doesn't require full Spring Boot context
- Use
@ExtendWith(MockitoExtension.class)not@SpringBootTest - Mock all dependencies—don't start the application
Problem: Code coverage tool shows gaps
Solution:
- Add more edge case tests
- Test exception paths
- Run
mvn test jacoco:reportto see coverage details
Problem: Copilot gives .NET code when you want Java or vice versa
Solution:
- Be explicit: "using Spring Boot" or "using .NET"
- Reference file context: "in this Java file..." or "in this C# file..."
- Check that correct instructions file is loading (status bar shows active instructions)
Problem: Tests sometimes pass, sometimes fail
Solution:
- Remove timing dependencies (use mocks, not actual delays)
- Avoid shared state between tests
- Use deterministic test data
Move on to Lab 2: Requirements → Backlog → Code where you'll:
- Convert user stories into backlog items with Copilot
- Generate acceptance criteria
- Build features from requirements
- Practice the full development workflow
- JUnit 5 User Guide
- Mockito Documentation
- Spring Boot Testing
- Clean Architecture in Spring Boot
- Spring Dependency Injection
- GitHub Copilot Documentation
- Test-Driven Development (TDD) Guide
- Pattern Translation Guide - .NET ↔ Spring Boot equivalencies