Loading learning content...
You've completed the refactoring—the inheritance hierarchy is gone, composition is in place, and all behavioral tests pass. But you're not done yet. Passing tests only prove correctness; they don't prove that the refactoring achieved its goals.
Remember why you refactored in the first place: the inheritance hierarchy was rigid, hard to extend, and difficult to maintain. The composition-based design was supposed to fix these problems. Now you need to verify that it actually did.
This final phase of the refactoring process validates that the new design delivers on its promises. We test not just that it works, but that it works better—that it's more flexible, more testable, more extensible, and more maintainable than what it replaced.
By the end of this page, you will understand how to comprehensively test a refactored composition-based design. You'll learn testing strategies for flexibility, extensibility, testability, and maintainability—the qualities that justified the refactoring investment.
Composition-based designs fundamentally change how you test. The testing pyramid shifts because the design itself has shifted.
With inheritance hierarchies, testing often looked like this:
With composition, the pyramid shifts:
| Aspect | Inheritance-Based | Composition-Based |
|---|---|---|
| Unit Test Isolation | Difficult (depends on parent classes) | Easy (inject mock dependencies) |
| Mocking | Complex (partial mocking, spy objects) | Trivial (interface-based mocking) |
| Test Setup | Heavy (construct full hierarchy) | Light (only instantiate target component) |
| Coverage Confidence | Moderate (inheritance obscures paths) | High (all paths explicit) |
| Test Maintenance | High (base class changes ripple) | Low (isolated component tests) |
For a composition-based design, structure your tests at three levels:
The bulk of your tests should be component tests—they're fastest, most isolated, and provide the clearest feedback when something breaks.
The primary benefit of composition is that each component can be tested in complete isolation. This section demonstrates how to leverage that benefit effectively.
Components should be designed for testability. Each method should have clear inputs and outputs with minimal side effects:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Pure unit tests for ContentFormatter components class HtmlEmailFormatterTest { private TemplateEngine templateEngine; private HtmlEmailFormatter formatter; @BeforeEach void setUp() { templateEngine = new InMemoryTemplateEngine(); templateEngine.register("email-template.html", "<html><body>{{content}}</body></html>"); formatter = new HtmlEmailFormatter(templateEngine); } @Test void format_simpleContent_wrapsInHtml() { String result = formatter.format( "Hello, World!", new FormattingContext("standard", Map.of()) ); assertEquals("<html><body>Hello, World!</body></html>", result); } @Test void format_contentWithHtmlChars_escapesCorrectly() { String result = formatter.format( "<script>alert('xss')</script>", new FormattingContext("standard", Map.of()) ); // Should escape dangerous HTML assertFalse(result.contains("<script>")); assertTrue(result.contains("<script>")); } @Test void format_withMetadata_includesInTemplate() { templateEngine.register("email-template.html", "<html><body>{{content}} - Sent to: {{recipientType}}</body></html>"); String result = formatter.format( "Hello", new FormattingContext("premium", Map.of()) ); assertTrue(result.contains("Sent to: premium")); } @Test void format_templateEngineThrows_propagatesException() { TemplateEngine failingEngine = mock(TemplateEngine.class); when(failingEngine.render(any(), any())) .thenThrow(new TemplateException("Parse error")); HtmlEmailFormatter f = new HtmlEmailFormatter(failingEngine); assertThrows(TemplateException.class, () -> f.format("content", new FormattingContext("", Map.of())) ); }} // Unit tests for RetryPolicy componentsclass ExponentialBackoffRetryPolicyTest { @Test void shouldRetry_firstAttempt_returnsTrue() { RetryPolicy policy = new ExponentialBackoffRetryPolicy( 3, Duration.ofSeconds(1), Set.of(IOException.class) ); assertTrue(policy.shouldRetry(0, new IOException())); } @Test void shouldRetry_atMaxRetries_returnsFalse() { RetryPolicy policy = new ExponentialBackoffRetryPolicy( 3, Duration.ofSeconds(1), Set.of(IOException.class) ); assertFalse(policy.shouldRetry(3, new IOException())); } @Test void shouldRetry_nonRetryableException_returnsFalse() { RetryPolicy policy = new ExponentialBackoffRetryPolicy( 3, Duration.ofSeconds(1), Set.of(IOException.class) ); // IllegalArgumentException is not in the retryable set assertFalse(policy.shouldRetry(0, new IllegalArgumentException())); } @Test void getDelay_calculatesExponentialBackoff() { RetryPolicy policy = new ExponentialBackoffRetryPolicy( 5, Duration.ofSeconds(1), Set.of(Exception.class) ); assertEquals(Duration.ofSeconds(1), policy.getDelay(0)); // 2^0 = 1 assertEquals(Duration.ofSeconds(2), policy.getDelay(1)); // 2^1 = 2 assertEquals(Duration.ofSeconds(4), policy.getDelay(2)); // 2^2 = 4 assertEquals(Duration.ofSeconds(8), policy.getDelay(3)); // 2^3 = 8 assertEquals(Duration.ofSeconds(16), policy.getDelay(4)); // 2^4 = 16 }}Composition makes dependency injection natural. Use mocks to test components in isolation from their collaborators:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// Testing with mock dependencies class EmailNotificationTest { @Mock ContentFormatter formatter; @Mock DeliveryChannel channel; @Mock NotificationValidator validator; @Mock RetryPolicy retryPolicy; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); // Default happy-path stubs when(validator.validate(any())) .thenReturn(new ValidationResult(true, List.of())); when(formatter.format(any(), any())) .thenReturn("formatted content"); when(channel.deliver(any())) .thenReturn(new DeliveryResult(true, "MSG-123", null)); when(retryPolicy.shouldRetry(anyInt(), any())) .thenReturn(false); } @Test void send_validNotification_callsComponentsInOrder() { EmailNotification notification = new EmailNotification( "user@example.com", "content", "subject", formatter, channel, validator, retryPolicy ); notification.send(); // Verify call sequence InOrder inOrder = inOrder(validator, formatter, channel); inOrder.verify(validator).validate(any()); inOrder.verify(formatter).format(any(), any()); inOrder.verify(channel).deliver(any()); } @Test void send_validationFails_doesNotFormat() { when(validator.validate(any())) .thenReturn(new ValidationResult(false, List.of("Invalid"))); EmailNotification notification = new EmailNotification( "invalid", "content", "subject", formatter, channel, validator, retryPolicy ); assertThrows(InvalidNotificationException.class, notification::send); // Formatter should never be called verifyNoInteractions(formatter); verifyNoInteractions(channel); } @Test void send_deliveryFails_retriesAccordingToPolicy() { when(channel.deliver(any())) .thenThrow(new DeliveryException("Failed")); when(retryPolicy.shouldRetry(eq(0), any())).thenReturn(true); when(retryPolicy.shouldRetry(eq(1), any())).thenReturn(true); when(retryPolicy.shouldRetry(eq(2), any())).thenReturn(false); when(retryPolicy.getDelay(anyInt())).thenReturn(Duration.ZERO); EmailNotification notification = new EmailNotification( "user@example.com", "content", "subject", formatter, channel, validator, retryPolicy ); assertThrows(DeliveryException.class, notification::send); // Should have tried 3 times (initial + 2 retries) verify(channel, times(3)).deliver(any()); } @Test void send_deliverySucceedsOnRetry_completesSuccessfully() { when(channel.deliver(any())) .thenThrow(new DeliveryException("Failed")) // First attempt fails .thenReturn(new DeliveryResult(true, "MSG", null)); // Second succeeds when(retryPolicy.shouldRetry(eq(0), any())).thenReturn(true); when(retryPolicy.getDelay(0)).thenReturn(Duration.ZERO); EmailNotification notification = new EmailNotification( "user@example.com", "content", "subject", formatter, channel, validator, retryPolicy ); assertDoesNotThrow(notification::send); verify(channel, times(2)).deliver(any()); }}Don't just test return values—verify that components interact correctly. Use InOrder, times(), and verifyNoInteractions() to confirm the orchestration logic is correct.
The refactoring's main goal was improving flexibility and extensibility. Write tests that explicitly validate these qualities.
Prove that new capabilities can be added without modifying existing code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Tests proving extensibility without modification class ExtensibilityTests { @Test @DisplayName("Adding a new delivery channel requires no modifications to existing code") void newDeliveryChannel_integratesWithoutChanges() { // Create a brand new delivery channel DeliveryChannel slackChannel = new DeliveryChannel() { @Override public DeliveryResult deliver(FormattedNotification notification) { // Slack-specific delivery logic return new DeliveryResult(true, "SLACK-123", null); } @Override public boolean supportsRecipient(String recipient) { return recipient.startsWith("#") || recipient.startsWith("@"); } }; // It works immediately with existing components NotificationValidator validator = new CompositeValidator(); ContentFormatter formatter = new PlainTextFormatter(4000); RetryPolicy retryPolicy = new NoRetryPolicy(); // No changes to EmailNotification or any other class needed // We can compose this new channel with existing components SlackNotification notification = new SlackNotification( "#general", "Hello, Slack!", formatter, slackChannel, validator, retryPolicy ); assertDoesNotThrow(notification::send); } @Test @DisplayName("Adding a new formatter requires no modifications to existing code") void newFormatter_integratesWithoutChanges() { // Create a brand new formatter for a new use case ContentFormatter markdownFormatter = new ContentFormatter() { @Override public String format(String rawContent, FormattingContext context) { return "# " + context.metadata().get("title") + " " + rawContent; } }; // Use it with existing delivery channel DeliveryChannel existingChannel = new SmtpDeliveryChannel(mock(SmtpClient.class)); EmailNotification notification = new EmailNotification( "user@example.com", "Body content", "Subject", markdownFormatter, // New formatter existingChannel, // Existing channel new CompositeValidator(), new NoRetryPolicy() ); // System works with the new component assertDoesNotThrow(notification::send); } @Test @DisplayName("Composing multiple new components works without changes") void multipleNewComponents_composeCorrectly() { // All new implementations ContentFormatter customFormatter = (content, ctx) -> "[CUSTOM] " + content; NotificationValidator customValidator = data -> new ValidationResult(data.recipient().length() > 0, List.of()); RetryPolicy customRetry = new RetryPolicy() { @Override public boolean shouldRetry(int attempt, Exception e) { return attempt < 5 && e instanceof SocketException; } @Override public Duration getDelay(int attempt) { return Duration.ofMillis(100 * attempt); } }; SmtpClient mockSmtp = mock(SmtpClient.class); when(mockSmtp.send(any(), any(), any())).thenReturn("MSG"); // All custom components work together EmailNotification notification = new EmailNotification( "test@test.com", "content", "subject", customFormatter, new SmtpDeliveryChannel(mockSmtp), customValidator, customRetry ); notification.send(); // Verify custom formatter was used verify(mockSmtp).send(any(), any(), contains("[CUSTOM]")); }}Prove that behavior can be varied at runtime through different compositions:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// Tests proving runtime flexibility class FlexibilityTests { @Test @DisplayName("Same notification class with different policies behaves differently") void differentPolicies_produceDifferentBehaviors() { SmtpClient mockSmtp = mock(SmtpClient.class); when(mockSmtp.send(any(), any(), any())) .thenThrow(new SmtpException("Failed")); DeliveryChannel channel = new SmtpDeliveryChannel(mockSmtp); ContentFormatter formatter = new PlainTextFormatter(1000); NotificationValidator validator = new CompositeValidator(); // Notification with aggressive retry EmailNotification aggressiveRetry = new EmailNotification( "user@example.com", "content", "subject", formatter, channel, validator, new ExponentialBackoffRetryPolicy(5, Duration.ZERO, Set.of(Exception.class)) ); // Notification with no retry EmailNotification noRetry = new EmailNotification( "user@example.com", "content", "subject", formatter, channel, validator, new NoRetryPolicy() ); // Both fail, but with different attempt counts assertThrows(DeliveryException.class, () -> { reset(mockSmtp); when(mockSmtp.send(any(), any(), any())) .thenThrow(new SmtpException("Failed")); aggressiveRetry.send(); }); verify(mockSmtp, times(5)).send(any(), any(), any()); assertThrows(DeliveryException.class, () -> { reset(mockSmtp); when(mockSmtp.send(any(), any(), any())) .thenThrow(new SmtpException("Failed")); noRetry.send(); }); verify(mockSmtp, times(1)).send(any(), any(), any()); } @Test @DisplayName("Factory can create different variations for different contexts") void factory_createsDifferentVariations() { NotificationFactory factory = new NotificationFactory( new InMemoryTemplateEngine(), mock(SmtpClient.class), mock(TwilioClient.class) ); // Marketing email gets tracking formatter + aggressive retry Sendable marketingEmail = factory.createMarketingEmail( "user@example.com", "Big Sale!", "campaignId" ); // Transactional email gets simple formatter + no retry Sendable transactionalEmail = factory.createTransactionalEmail( "user@example.com", "order123", "Your order shipped" ); // Verify they're configured differently // (In real tests, you'd verify through behavior or introspection) assertNotEquals( marketingEmail.getClass().getSimpleName(), transactionalEmail.getClass().getSimpleName() ); } @Test @DisplayName("Components can be swapped at runtime via dependency injection") void runtimeSwapping_changesBehhavior() { // Start with production formatter ContentFormatter productionFormatter = new HtmlEmailFormatter( new ProductionTemplateEngine() ); // Create debug formatter that adds diagnostics ContentFormatter debugFormatter = new ContentFormatter() { private final ContentFormatter delegate = productionFormatter; @Override public String format(String content, FormattingContext ctx) { String result = delegate.format(content, ctx); return "<!-- DEBUG: " + ctx + " -->" + result; } }; // Same notification type, different formatter based on context boolean isDebugMode = true; ContentFormatter formatterToUse = isDebugMode ? debugFormatter : productionFormatter; // This flexibility was impossible with inheritance assertNotNull(formatterToUse); }}Maintainability is harder to test directly, but you can create tests that demonstrate maintainability characteristics.
Demonstrate that changes are localized—modifying one component doesn't break others:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Tests demonstrating localized change impact class MaintainabilityTests { @Test @DisplayName("Changing formatter doesn't affect delivery channel tests") void formatterChange_doesNotAffectChannelTests() { // This test passes even if HtmlEmailFormatter implementation changes SmtpClient mockSmtp = mock(SmtpClient.class); when(mockSmtp.send(any(), any(), any())).thenReturn("MSG-ID"); SmtpDeliveryChannel channel = new SmtpDeliveryChannel(mockSmtp); // Channel tests don't depend on formatter implementation FormattedNotification notification = new FormattedNotification( "Any content", // Channel doesn't care how this was formatted Map.of("to", "user@example.com", "subject", "Test") ); DeliveryResult result = channel.deliver(notification); assertTrue(result.success()); verify(mockSmtp).send("user@example.com", "Test", "Any content"); } @Test @DisplayName("Adding new validation rule only affects validator tests") void newValidationRule_isolatedImpact() { // Add a new validation rule NotificationValidator maxLengthValidator = data -> { if (data.content().length() > 10000) { return new ValidationResult(false, List.of("Content too long")); } return new ValidationResult(true, List.of()); }; // This validator can be tested completely independently assertTrue(maxLengthValidator.validate( new NotificationData("r", "short", Map.of()) ).valid()); assertFalse(maxLengthValidator.validate( new NotificationData("r", "x".repeat(10001), Map.of()) ).valid()); // No changes to formatter, channel, or retry tests needed } @Test @DisplayName("Component tests remain valid when other components change") void componentTestIsolation_demonstratesMaintainability() { // Even if we completely rewrite HtmlEmailFormatter, // these SmsFormatter tests remain valid SmsFormatter smsFormatter = new SmsFormatter(" STOP"); assertEquals("Hello STOP", smsFormatter.format("Hello", new FormattingContext("", Map.of()))); assertEquals(160, smsFormatter.format("x".repeat(200), new FormattingContext("", Map.of())).length()); // These tests will still pass tomorrow, regardless of email changes }}Verify that each component has a single, focused responsibility:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// Tests that verify single responsibility class SingleResponsibilityTests { @Test @DisplayName("ContentFormatter only formats, never validates or delivers") void contentFormatter_onlyFormats() { ContentFormatter formatter = new HtmlEmailFormatter(new InMemoryTemplateEngine()); // Formatter accepts ANY input—validation is someone else's job assertDoesNotThrow(() -> formatter.format(null, new FormattingContext("", Map.of())) ); assertDoesNotThrow(() -> formatter.format("", new FormattingContext("", Map.of())) ); // Formatter has no delivery capability—that's Channel's job // (Can't even test this because there's no send method to call!) } @Test @DisplayName("NotificationValidator only validates, never formats or delivers") void notificationValidator_onlyValidates() { NotificationValidator validator = new RecipientValidator(); // Validator returns pass/fail, never modifies content ValidationResult result = validator.validate( new NotificationData("invalid", "content", Map.of()) ); assertFalse(result.valid()); assertFalse(result.errors().isEmpty()); // Validator can't format or deliver—not its responsibility } @Test @DisplayName("DeliveryChannel only delivers, never validates or formats") void deliveryChannel_onlyDelivers() { SmtpClient mockSmtp = mock(SmtpClient.class); when(mockSmtp.send(any(), any(), any())).thenReturn("MSG"); DeliveryChannel channel = new SmtpDeliveryChannel(mockSmtp); // Channel delivers whatever it's given—formatting happened upstream channel.deliver(new FormattedNotification( "pre-formatted content", Map.of("to", "user@test.com", "subject", "Test") )); verify(mockSmtp).send(any(), any(), eq("pre-formatted content")); // Channel trusts that validation happened—not its job to check } @Test @DisplayName("RetryPolicy only decides retry logic, never executes delivery") void retryPolicy_onlyDecides() { RetryPolicy policy = new ExponentialBackoffRetryPolicy( 3, Duration.ofSeconds(1), Set.of(IOException.class) ); // Policy makes decisions but doesn't act on them boolean shouldRetry = policy.shouldRetry(0, new IOException()); Duration delay = policy.getDelay(0); assertTrue(shouldRetry); assertEquals(Duration.ofSeconds(1), delay); // Policy has no way to actually perform a retry—that's orchestration's job }}While unit tests verify individual components, integration tests verify that components work together correctly. With composition, integration tests become more focused—you're testing specific collaboration patterns, not entire inheritance hierarchies.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// Integration tests for component composition class NotificationIntegrationTests { @Test @DisplayName("Full email sending pipeline integrates correctly") void emailPipeline_endToEnd() { // Real components with only external services mocked TemplateEngine templateEngine = new MustacheTemplateEngine(); templateEngine.register("email.html", "<html><body>{{content}}</body></html>"); SmtpClient mockSmtp = mock(SmtpClient.class); when(mockSmtp.send(any(), any(), any())).thenReturn("MSG-123"); ContentFormatter formatter = new HtmlEmailFormatter(templateEngine); DeliveryChannel channel = new SmtpDeliveryChannel(mockSmtp); NotificationValidator validator = new CompositeValidator( new RecipientValidator(), new ContentLengthValidator(50000) ); RetryPolicy retryPolicy = new ExponentialBackoffRetryPolicy( 3, Duration.ZERO, Set.of(SmtpException.class) ); EmailNotification email = new EmailNotification( "user@example.com", "Hello, World!", "Test Subject", formatter, channel, validator, retryPolicy ); email.send(); verify(mockSmtp).send( eq("user@example.com"), eq("Test Subject"), eq("<html><body>Hello, World!</body></html>") ); } @Test @DisplayName("Retry logic integrates with delivery channel correctly") void retryIntegration_worksCorrectly() { SmtpClient mockSmtp = mock(SmtpClient.class); when(mockSmtp.send(any(), any(), any())) .thenThrow(new SmtpException("Temporary failure")) .thenThrow(new SmtpException("Temporary failure")) .thenReturn("MSG-123"); // Success on third attempt DeliveryChannel channel = new SmtpDeliveryChannel(mockSmtp); RetryPolicy retryPolicy = new ExponentialBackoffRetryPolicy( 3, Duration.ZERO, Set.of(SmtpException.class) ); EmailNotification email = new EmailNotification( "user@example.com", "content", "subject", new PlainTextFormatter(1000), channel, new NoOpValidator(), retryPolicy ); // Should succeed after retries assertDoesNotThrow(email::send); verify(mockSmtp, times(3)).send(any(), any(), any()); } @Test @DisplayName("Validation prevents delivery of invalid notifications") void validationIntegration_preventsInvalidDelivery() { SmtpClient mockSmtp = mock(SmtpClient.class); NotificationValidator strictValidator = new CompositeValidator( new RecipientValidator(), // Will reject empty recipient new ContentLengthValidator(100) // Will reject long content ); EmailNotification invalidRecipient = new EmailNotification( "", // Invalid: empty recipient "content", "subject", new PlainTextFormatter(1000), new SmtpDeliveryChannel(mockSmtp), strictValidator, new NoRetryPolicy() ); assertThrows(InvalidNotificationException.class, invalidRecipient::send); // SMTP should never have been called verifyNoInteractions(mockSmtp); }}Test that factories compose components correctly for each product type:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// Factory integration tests class NotificationFactoryIntegrationTest { private NotificationFactory factory; private SmtpClient mockSmtp; private TwilioClient mockTwilio; @BeforeEach void setUp() { mockSmtp = mock(SmtpClient.class); mockTwilio = mock(TwilioClient.class); when(mockSmtp.send(any(), any(), any())).thenReturn("MSG"); when(mockTwilio.sendSms(any(), any())).thenReturn("SMS"); factory = new NotificationFactory( new InMemoryTemplateEngine(), mockSmtp, mockTwilio ); } @Test @DisplayName("Factory creates working email notifications") void createEmailNotification_works() { Sendable email = factory.createEmailNotification( "user@example.com", "Hello!", "Subject" ); email.send(); verify(mockSmtp).send(eq("user@example.com"), eq("Subject"), any()); verifyNoInteractions(mockTwilio); } @Test @DisplayName("Factory creates working SMS notifications") void createSmsNotification_works() { Sendable sms = factory.createSmsNotification( "+1234567890", "Hello via SMS!" ); sms.send(); verify(mockTwilio).sendSms(eq("+1234567890"), any()); verifyNoInteractions(mockSmtp); } @Test @DisplayName("Marketing and transactional emails use different retry policies") void differentEmailTypes_differentRetryBehavior() { when(mockSmtp.send(any(), any(), any())) .thenThrow(new SmtpException("Failed")); // Marketing email: retries (we want to reach the customer) Sendable marketing = factory.createMarketingEmail( "user@example.com", "Sale!", "campaign123" ); assertThrows(DeliveryException.class, marketing::send); int marketingAttempts = Mockito.mockingDetails(mockSmtp) .getInvocations().size(); reset(mockSmtp); when(mockSmtp.send(any(), any(), any())) .thenThrow(new SmtpException("Failed")); // Transactional email: no retry (idempotency is critical) Sendable transactional = factory.createTransactionalEmail( "user@example.com", "order123", "Order shipped" ); assertThrows(DeliveryException.class, transactional::send); int transactionalAttempts = Mockito.mockingDetails(mockSmtp) .getInvocations().size(); assertTrue(marketingAttempts > transactionalAttempts, "Marketing should retry more than transactional"); }}Refactoring should not degrade performance. In fact, composition often improves performance by eliminating unnecessary inheritance overhead. Verify with performance tests.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Performance tests comparing old vs. new design class PerformanceComparisonTests { private static final int ITERATIONS = 10_000; @Test @DisplayName("New composition design is not slower than inheritance design") void performanceNotDegraded() { // Baseline: Legacy inheritance-based design long legacyTime = measureLegacyDesign(ITERATIONS); // New: Composition-based design long compositionTime = measureCompositionDesign(ITERATIONS); // Composition should be no more than 10% slower double ratio = (double) compositionTime / legacyTime; assertTrue(ratio < 1.1, String.format("Composition too slow: %.2fx legacy speed", ratio)); System.out.printf("Legacy: %dms, Composition: %dms, Ratio: %.2f%n", legacyTime, compositionTime, ratio); } private long measureLegacyDesign(int iterations) { LegacyEmailNotification template = new LegacyEmailNotification( "user@example.com", "subject", "body" ); long start = System.currentTimeMillis(); for (int i = 0; i < iterations; i++) { template.formatContent(); // Key operation being measured } return System.currentTimeMillis() - start; } private long measureCompositionDesign(int iterations) { ContentFormatter formatter = new HtmlEmailFormatter( new InMemoryTemplateEngine() ); FormattingContext context = new FormattingContext("", Map.of()); long start = System.currentTimeMillis(); for (int i = 0; i < iterations; i++) { formatter.format("body", context); // Same operation } return System.currentTimeMillis() - start; } @Test @DisplayName("Object creation overhead is acceptable") void objectCreationOverhead_acceptable() { NotificationFactory factory = new NotificationFactory( new InMemoryTemplateEngine(), mock(SmtpClient.class), mock(TwilioClient.class) ); long start = System.currentTimeMillis(); for (int i = 0; i < ITERATIONS; i++) { factory.createEmailNotification( "user@example.com", "content", "subject" ); } long duration = System.currentTimeMillis() - start; // Should create at least 100K notifications per second double perSecond = ITERATIONS / (duration / 1000.0); assertTrue(perSecond > 100_000, String.format("Creation too slow: %.0f/sec", perSecond)); }}Composition can use more memory than inheritance due to additional object references. Profile memory usage in addition to CPU performance, especially for high-volume systems.
Define clear success criteria to know when the refactoring is truly complete and successful.
| Metric | Before Refactoring | Target After | How to Measure |
|---|---|---|---|
| Test Count | N (baseline) | N + 30% (more granular) | Count test methods |
| Test Execution Time | X seconds | < 0.8X (faster isolation) | Test suite duration |
| Code Coverage | Y% | Y% (easier to test) | Coverage tool |
| Mutation Score | Z% | Z% (stronger tests) | Mutation testing |
| Lines Per Test | High (setup heavy) | Lower (focused tests) | Average test LOC |
| Mock Complexity | Complex partial mocks | Simple interface mocks | Manual assessment |
Beyond numbers, assess qualitative improvements:
The ultimate test of successful refactoring: can you add a completely new notification type (e.g., WhatsApp) by only adding new code? If yes—without modifying existing files—the refactoring has achieved its goal.
You've completed the learning journey from inheritance to composition. Let's consolidate what we've covered across this entire module:
Congratulations! You now possess the knowledge to identify problematic inheritance hierarchies, systematically transform them into composition-based designs, maintain behavioral correctness throughout the process, and validate that the refactored design achieves its goals. This is a skill that will serve you throughout your engineering career.