[JAVA] Code testable

Aperçu

Cet article concerne la refactorisation des sources publiées ci-dessous.

Écrire du code difficile à tester https://qiita.com/oda-kazuki/items/bac33094e82b0f51da41

Conclusions du code testable

Codons comme suit.

Faire cela rend les tests beaucoup plus faciles. Refactorisons maintenant le code précédent.

Augmenter le degré d'agrégation

Dernière fois Comme je l'ai écrit, Login Service a joué plusieurs rôles. Tout d'abord, nous arrêterons le plus dangereux d'entre eux, "l'appel direct à l'API Web". Supprimons-le.

Créer un modèle

Mais avant cela, créez une classe d'informations de compte et d'informations utilisateur exprimées dans Map. En fait, j'aimerais ajouter divers traitements à l'intérieur, mais cette fois, il semble ne pas être utilisé en particulier, donc cela ressemble à un DTO.

@Data 
public class AuthToken {
   private String token;
}

@Data 
public class AuthResult {
   private String accountId;
}

@Data
public class Account {
   //Je ne vais pas l'utiliser cette fois, mais je vais le mettre dans le sens qu'il y a quelque chose comme ça
   private String accountId;
}

@Data
public class User {
   //Je ne vais pas l'utiliser cette fois, mais je vais le mettre dans le sens qu'il y a quelque chose comme ça
   private String name;
}

Créer une méthode d'usine

De plus, cette fois, j'utilise une bibliothèque appelée JsonMapper (paramètre), mais c'est un peu difficile à utiliser car elle doit spécifier les génériques. Nous allons donc également créer une méthode d'usine pour cela. C'est une classe simple qui crée simplement le JsonMapper cible.

public class JsonMapperFactory {
    public JsonMapper<Account> createAccountMapper() {
        return new JsonMapper<>();
    }

    public JsonMapper<AuthToken> createAuthMapper() {
        return new JsonMapper<>();
    }

    public JsonMapper<AuthResult> createAuthResultMapper() {
        return new JsonMapper<>();
    }

    public JsonMapper<User> createUserMapper() {
        return new JsonMapper<>();
    }    
}

Verrouiller l'API Web

Alors, créons d'abord quelque chose appelé HttpClient. Limitez-y la partie qui appelle directement l'API Web.

public abstract class HttpClient {
    protected final Client client;
    protected final JsonMapperFactory mapperFactory;

    public HttpClient(Client client, JsonMapperFactory factory) {
        this.client = client;
        this.mapperFactory = factory;
    }
}

Maintenant, créons ʻAuthClient et ʻUserClient qui appellent réellement l'API Web.

public class AuthClient extends HttpClient {
    public AuthClient(Client client, JsonMapperFactory factory) {
        super(client, factory);
    }

    public AuthResult authorize(String accessToken) throws HttpException {
        JsonMapper<AuthToken> tokenMapper = factory.createAuthMapper();
        AuthToken token = new AuthToken(accessToken);
        Json json = client.post("/auth" , tokenMapper.serialize(token)).body();

        JsonMapper<AuthResult> resultMapper = factory.createAuthResultMapper();
        return resultMapper.deserialize(json);
    }

    public Account fetchAccount(String accountId) throws HttpException {
        Json json = client.get("/accounts/" + accountId).body();

        JsonMapper<Account> accountMapper = factory.createAccountMapper();
        return accountMapper.deserialize(json);
    }
}

public class UserClient extends HttpClient {
    public UserClient(Client client, JsonMapperFactory factory) {
        super(client, factory);
    }

    public User fetchUser(Account account) throws HttpException {
        JsonMapper<Account> accountMapper = factory.createAccountMapper();
        Json json = client.post("/users" , accountMapper.serialize(account)).body();
        
        JsonMapper<User> userMapper = factory.createUserMapper();
        return userMapper.deserialize(json);
    }

    public void logout(User user) throws HttpException {
        JsonMapper<User> userMapper = factory.createUserMapper();
        client.post("/logout", userMapper.serialize(user));
    }
}

Correction de la classe de cache

Puis modifiez également le cache. Finalement, il sera utilisé comme un singleton, mais une fois que la description dans le modèle Singleton sera perdue.

public class Cache {
    private User loggedInUser;
    @Getter @Setter
    private String accessToken;
    @Getter 
    private Account loggedInAccount;

    public Cache() {
        this.reset();
    }

    public boolean isLoggedIn() {
        return loggedInUser != null;
    }

    public String cacheToken(String accessToken) {
        if (accessToken != null) {
            this.accessToken = accessToken;
        }
        return this.accessToken;
    }

    public void cacheAll(String accessToken, Account account, User user) {
        this.accessToken = accessToken;
        this.loggedInAccount = account;
        this.loggedInUser = user;
    }

    public void reset() {
        this.accessToken = null;
        this.loggedInAccount = null;
        this.loggedInUser = null;
    }
}

En regardant ça comme ça, je pense que c'est beaucoup de gaspillage, mais c'est une refactorisation, alors laissons les choses telles quelles.

Dependency Injection Vous êtes maintenant prêt pour la refactorisation. Mais avant cela, je présenterai brièvement DI (Dependency Injection), qui est la clé pour écrire du code testable.

Si vous voulez en savoir plus, je pense que vous devriez voir ce qui suit.

Même les singes peuvent comprendre! Injection de dépendance: injection de dépendance https://qiita.com/hshimo/items/1136087e1c6e5c5b0d9f

Cela s'appelle «injection de dépendances» en japonais. Comme son nom l'indique, il s'agit d'un modèle de conception qui place des fichiers dépendants de l'extérieur.

La DI la plus simple consiste à passer une variable membre dans le constructeur. Par exemple, les méthodes «AuthClient» et «UserClient» ci-dessus sont utilisées.

Republier


public class UserClient extends HttpClient {
    public UserClient(Client client, JsonMapperFactory factory) {
        super(client, factory);
    }
    // ︙
}

Ce qui est bon à ce sujet

Lors du test, vous pouvez facilement Mock les fichiers dépendants. Fondamentalement, ** les classes dépendent des variables membres **. Si ce sont des données, elles peuvent être traitées comme de simples "informations", mais si cela dépend de la méthode, il peut être nécessaire de les faire simuler au moment du test.

Le modèle d'injection de dépendance facilite le remplacement de ces pièces dépendantes par moquage lors des tests.

Google Guice Pour Java, il existe de nombreuses bibliothèques DI puissantes telles que Google Guice et Spring. Utilisons Google Guice cette fois.

Correction du service de connexion

Maintenant, corrigeons le service de connexion que nous voulions tester cette fois. Avant cela, supposons qu'il existe un nouveau service appelé «LogoutService». (Comme c'est déjà gênant, je n'écrirai que l'interface)

public interface LogoutService {
    /**Si la connexion réussit, supprimez tout le cache et renvoyez true. Renvoie false si la déconnexion échoue*/
    boolean logout();
}

C'est donc le service de connexion.

public class LoginService {
   @Inject //Injection de dépendance à Guice
   private Cache cache;
   @Inject 
   private AuthClient authClient;
   @Inject 
   private UserClient userClient;
   @Inject
   private LogoutService logoutService;

   public User login(String authToken) {
       if(cache.isLoggedIn()) {
           if(!logoutService.logout()) {
               return null;
           }
       }
       String token = cache.cacheToken(authToken);
       return this.executeLogin(token, 0);
   }

   private User executeLogin(String token, int retryCount) {
       if (retryCount >= 3) {
           return null;
       }
       try {
          AuthResult auth = authClient.aurhotize(token);
          
          Account account = cache.getAccount();
          if (account == null) {
              account = authClient.fetchAccount(auth.getAccountId());
          }

          User user = userClient.fetchUser(account);
          if (user == null) {
              return null;
          }

          cache.cacheAll(token, account, user);
          return user;
       } catch (HttpException e) {
          if (e.getReason() == HttpException.Reason.TIMEOUT) {
              return this.executeLogin(authToken, ++retryCount);
          }
          return null;
       }
   }
}

C'est beaucoup plus simple. Lors de l'utilisation de cette classe, Guice l'injecte comme suit.

Injector injector = Guice.createInjector(() -> {
    bind(Cache.class).toProvider(() -> {
        return new Cache();
    }).asEagerSingleton();

    bind(AuthClient.class).toProvider(() -> {
        return new AuthClient(HttpClientFactory.createClient("https://account.example.com"), new JsonMapperFactory());
    }).asEagerSingleton();

    bind(UserClient.class).toProvider(() -> {
        return new UserClient(HttpClientFactory.createClient("https://example.com"), new JsonMapperFactory());
    }).asEagerSingleton();

    bind(LogoutService.class).to(LogoutServiceImpl.class);
});

//Génération d'instance
LoginService service = injector.getInstance(LoginService.class);

Notes d'utilisation de Google Guice https://qiita.com/opengl-8080/items/6fb69cd2493e149cac3a

Test réel

Maintenant, écrivons un exemple de test. Étant donné que les variables membres peuvent être injectées, vous pouvez facilement tester en injectant Mock même dans le test.

class LoginServiceTest {
   @Mock
   Cache cache;
   @Mock 
   AuthClient authClient;
   @Mock 
   UserClient userClient;
   @Mock 
   LogoutService logoutService;

   Injector injector;

   @Before 
   public void setup() {
      injector = Guice.createInjector(() -> {
          bind(Cache.class).toInstance(this.cache);
          bind(AuthClient.class).toInstance(this.authClient);
          bind(UserClient.class).toInstance(this.userClient);
          bind(LogoutService.class).toInstance(this.logoutService);
      });
   }

   @Test 
public void Vous pouvez vous connecter normalement() {
       Account account = new Account("accountId");
       User expectedUser = new User("name001");
       LoginService test = injector.getInstance(LoginService.class);

       when(cache.isLoggedIn()).thenReturn(false);
       when(cache.cacheToken("token01")).thenReturn("token01");
       when(authClient.authorize("token01")).thenReturn(new AuthResult("account01"));
       when(cache.getAccount()).thenReturn(null);
       when(authClient.fetchAccount("account01")).thenReturn(account);
       when(userClient.fetchUser(account)).thenReturn(expectedUser);
       
       User actualUser = test.login("token01");
       Assert.that(actualUser, is(expectedUser));
       //Appelé/Confirmation de ne pas être appelé
       verify(logoutService, never()).logout();
       verify(cache, times(1)).cacheAll("token01", acount, expectedUser);
   }
}

C'est comme ça. Je pense que c'est beaucoup plus facile à tester que le premier. Le nombre total de tests peut ne pas changer beaucoup car il y a plus de classes, mais ** la facilité de création de tests pour chaque classe devrait être beaucoup plus facile qu'elle ne l'était au début **.

Pour faciliter le test

J'ai omis cette fois, mais en réalité, cet ʻexecuteLogin` pourrait être rendu encore plus facile. Par exemple, lorsque vous obtenez un jeton d'accès, vous obtiendrez généralement des informations utilisateur, etc. à la fin, supprimez donc la classe de service qui effectue le traitement suivant, transmettez le jeton d'accès et obtenez les informations sur le compte et l'utilisateur. La création d'une classe de service à obtenir ** permettra à LoginService de se concentrer uniquement sur les tentatives et la gestion du cache **.

AuthResult auth = authClient.aurhotize(token);

//Je lis le cache ici, je dois donc réfléchir à la manière de le laisser dans le service de connexion et de supprimer tout ce processus.
Account account = cache.getAccount();
if (account == null) {
    account = authClient.fetchAccount(auth.getAccountId());
}

User user = userClient.fetchUser(account);
if (user == null) {
    return null;
}

Certaines personnes peuvent s'inquiéter du fait que "** Si vous divisez trop la classe, vous ne saurez pas ce qui se passe **", mais si vous vous souciez du nom et de la structure du package, il n'y a pas de problème. En fait, j'ai moi-même créé un système avec plus de 1000 classes, mais je ne savais même pas quoi mettre dans quel paquet.

Au fait, qu'est-ce que JsonMapperFactory?

Ceci est également omis, mais en créant cette JsonMapperFactory, il sera plus facile de tester ʻUserClient et ʻAuthClient cette fois. Cela est dû au fait que ** Client et JsonMapperFactory peuvent être simulés ** dans le test à ce moment-là.

Ce client et JsonMapper sont des bibliothèques externes (avec un paramètre appelé), vous n'avez donc pas besoin de les tester. Donc, si vous pouvez facilement le convertir en Mock, ce sera très facile à tester.

Ainsi, encapsuler la génération de bibliothèques externes est un ** second atout ** pour écrire du code testable. Vous n'êtes pas obligé de tout faire, mais si c'est générique comme JsonMapper cette fois, il est très utile de créer une classe d'usine.

finalement

Une autre chose importante est la lisibilité du code. Comme je l'ai écrit dans Écrire du code difficile à tester, il est très important de comprendre correctement le processus afin de tester correctement. Par conséquent, je pense qu'il vaut mieux garder la partie de conclusion que j'ai écrite au début.

Recommended Posts

Code testable
Exécuter du code Java de manière scriptée
CONSEILS relatifs au code Java
Exemple de code Java 02
Exemple de code Java 03
Points de révision du code
Exemple de code Java 01
Code de caractère Java
Code d'écriture Ruby