Guía práctica
El singleton que no puedes sustituir en un test
Un singleton parece simplificar el acceso a una dependencia, pero impide inyectar un doble local y obliga a los tests a usar el servicio real o modificar estado global.
Un singleton parece una forma cómoda de compartir un servicio:
public final class EmailSender {
private static final EmailSender INSTANCE = new EmailSender();
private EmailSender() {}
public static EmailSender getInstance() {
return INSTANCE;
}
public void sendWelcomeTo(String email) {
// Envía un email mediante SMTP
}
}Cualquier clase puede obtenerlo sin declararlo como dependencia:
public final class WelcomeService {
public void welcome(String email) {
EmailSender.getInstance().sendWelcomeTo(email);
}
}La llamada es corta, pero WelcomeService ha decidido por su cuenta qué implementación usar. Su constructor no permite entregar otra.
El test no puede inyectar un doble#
Queremos comprobar que se solicita el envío sin mandar un email real:
@Test
void sendsTheWelcomeEmail() {
var service = new WelcomeService();
service.welcome("ana@example.com");
// ¿Cómo sustituimos EmailSender.getInstance() por un doble?
}No podemos hacerlo mediante la API de WelcomeService. La dependencia está escondida dentro del método.
Podríamos añadir un setter al singleton. Entonces cada test tendría que modificar una dependencia compartida y restaurarla después. Un olvido, otro test en paralelo o un orden distinto de ejecución puede cambiar el resultado.
También podríamos usar una herramienta que intercepte métodos estáticos, pero el test quedaría acoplado al mecanismo concreto: tendría que saber que el servicio llama a getInstance().
El problema no es solo que el test sea incómodo. Es que el consumidor no gobierna sus dependencias.
Haz explícita la dependencia#
El servicio puede recibir la capacidad que necesita:
@FunctionalInterface
public interface EmailSender {
void sendWelcomeTo(String email);
}
public final class WelcomeService {
private final EmailSender emailSender;
public WelcomeService(EmailSender emailSender) {
this.emailSender = emailSender;
}
public void welcome(String email) {
emailSender.sendWelcomeTo(email);
}
}Ahora el test entrega un doble local:
@Test
void sendsTheWelcomeEmail() {
var sentTo = new ArrayList<String>();
var service = new WelcomeService(sentTo::add);
service.welcome("ana@example.com");
assertEquals(List.of("ana@example.com"), sentTo);
}No se envía ningún email, no se cambia estado global y otro test puede usar un doble distinto. La prueba observa la colaboración prometida sin depender de SMTP ni de un framework de mocking estático.
Una sola instancia no exige un singleton#
La aplicación todavía puede crear un único sender en su punto de composición:
EmailSender emailSender = new SmtpEmailSender(configuration);
WelcomeService welcomeService = new WelcomeService(emailSender);En producción hay una sola instancia. La diferencia es que su ciclo de vida se decide al construir la aplicación y no desde cada consumidor mediante getInstance().
No fomentes el acceso global para obtener una única instancia. Créala una vez e inyéctala donde haga falta: las dependencias quedan visibles y los tests pueden sustituirlas por dobles locales.