• Non classé
  • 0

Injection de Dépendances et Tests Unitaires

Il semble très difficile, voir impossible, d’effectuer des tests unitaires sous Android. Trop souvent sont remis en cause les Activités, Fragments et Vues, qui possèdent leurs propre cycles de vies et qui utilisent des méthodes propres au système, et dépendantes d’un Context. Je vais ici vous montrer comment il est assez simple de construire une architecture testable, en utilisant le principe d’injection de dépendances, couplé avec des outils de tests unitaires tels que Junit et Mockito.

Partons de l’activité suivante, qui modifie le contenu d’une TextView au click sur le bouton login

public class UserActivity extends Activity{

    @Bind(R.id.text) TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_user);
        Butterknife.bind(this);
    }

    @OnClick(R.id.login)
    public void displayLoggedUser(){
        User user = UserStorage.getInstance().loadUser(this);
        textView.setText(user.getName());
    }

}

Ici la classe UserStorage est utilisée en tant que Singleton, ce qui est une pratique assez courante dans le développement Android. Cet objet va stocker les informations de l’utilisateur dans les SharedPreferences, celles-ci étant liées à un context, nous sommes obligé de le fournir à chaque appel de fonction (pour rappel les Activity étendent Context).

public class UserStorage {

    static Gson gson = new Gson();

    private static UserStorage INSTANCE = null;
    private UserStorage(){}

    public static UserStorage getInstance(){
        if(INSTANCE == null)
             INSTANCE = new UserStorage();
        return INSTANCE;
    }

    protected SharedPreferences getSharedPreferences(Context context){
        return context.getSharedPreferences("user",Context.MODE_PRIVATE);
    }

    public void saveUser(Context context, User user){
        getSharedPreferences(context).edit()
                .putString("user",gson.toJson(user))
                .apply();
    }

    public User loadUser(Context context){
        String userJson = getSharedPreferences(context).getString("user", null),
        return gson.fromJson(userJson, User.class);
    }

}

Très classique, ce singeton sérialize l’utilisateur en utilisant GSON, puis le stock dans les SharedPreferences « user ». Les SharedPreferences sont récupérées à chaque appel de fonction en utilisant le context fournit.

Cette architecture fonctionne très bien pour la plupart des projets Android, le problème arrive dès lors que l’on souhaite effectuer des tests unitaires : imaginons que nous souhaitions assurer la valeur du user retourné par le UserStorage, en temps normale nous aurions l’habitude de mocker la méthode loadUser

UserStorage userStorage = Mockito.mock(UserStorage.class);

given(userStorage.loadUser(any(Context.class))).willReturn(new User("florent"));

Le problème ici est que UserStorage est récupéré par UserActivity le via la méthode UserStorage.getInstance(), qui ne peux être mockée / surchargée, nous serions donc ici dans l’incapacité de tester cette Activité en contrôlant le UserStorage proprement.

Injection de dépendances

Citons un peu wikipédia : « L’injection de dépendances est un mécanisme qui permet d’implémenter le principe de l’inversion de contrôle. Il consiste à créer dynamiquement (injecter) les dépendances entre les différentes classes en s’appuyant sur une description (fichier de configuration ou métadonnées) ou de manière programmatique. Ainsi les dépendances entre composants logiciels ne sont plus exprimées dans le code de manière statique mais déterminées dynamiquement à l’exécution. »

Si je résume simplement, au lieu de gérer manuellement l’utilisation ou la création d’une classe par mon Activité, je vais déléguer ce travail à mon outil d’injection, , je vais donc passer du code dit dépendant

UserStorage userStorage;

void onCreate(){
    userStorage = UserStorage.getInstance();
    User user = userStorage.loadUser(this);
}


Au code injecté


@Inject UserStorage userStorage;

void onCreate(){
    INJECTEUR.inject(this);
    User user = userStorage.loadUser();
}


Le code en deviens beaucoup plus lisible, et il n'est plus nécessaire de passer nos multiples arguments à chaque appel de fonction, l'outil de dépendance injectera dans notre cas automatiquement un Context dans UserStorage.

<h1>Dagger2</h1>

Dagger2 est la deuxième version de l’outil d’injection de dépendances Dagger, initialement porté par Square, puis reprit par Google : http://google.github.io/dagger/. Cette seconde version permet de résoudre l'arbre des dépendances lors de la compilation, ce qui est très peux couteux en terme de performances, contrairement à sa version précédente.


compile 'com.google.dagger:dagger:2.0.1'
apt 'com.google.dagger:dagger-compiler:2.0.1'
provided 'org.glassfish:javax.annotation:10.0-b28'

Module

La première étape de Dagger2 est de définir un module.
Précédé de l’annotation @Module, cette classe permet de fournir les composants à injecter dans notre application.

Le terme composants désigne les objets que nous allons vouloir laisser à la charge de notre outil d’injection.
Dans notre cas nous pouvons mettre en évidence les 4 composants suivants :

    • Le context, nécessaire pour la création d’un SharedPreferences
    • Une instance de GSON
    • Un SharedPreferences nommé « user »
    • Le Singleton UserStorage
@Module
public class MyModule {

    private final Application application;

    public MyModule(PMUApplication application) {
        this.application = application;
    }

    @Provides
    public Context provideContext() {
        return application;
    }

    @Provides
    @Singleton
    public UserStorage provideUserStorage(Context context, Gson gson, @Named("user") SharedPreferences sharedPreferences) {
        return new UserStorage(context,gson,sharedPreferences);
    }

    @Provides
    @Singleton
    @Named("user")
    public SharedPreferences provideUserSharedPreferences(Context context) {
        return context.getSharedPreferences("user", Context.MODE_PRIVATE);
    }

    @Provides
    @Singleton
    public Gson provideGson() {
        return new Gson();
    }
}

Modifions simplement UserStorage afin qu’il reçoive le Context, un Gson et une instance de SharedPreferences lors de sa création.

public class UserStorage {

    final Context context;
    final Gson gson;
    final SharedPreferences sharedPreferences;

    public UserStorage(Context context, Gson gson, SharedPreferences sharedPreferences){
        this.context = context;
        this.gson = gson;
        this.sharedPreferences = sharedPreferences;
    }

    public void saveUser(User user){
        sharedPreferences.edit()
                .putString("user",gson.toJson(user))
                .apply();
    }

    public User loadUser(){
        String userJson = sharedPreferences.getString("user", null),
        return gson.fromJson(userJson, User.class);
    }

}

Notez que l’injection par constructeur aurait pu être utilisée aussi, il aurait suffit d’ajouter l’annotation @Singleton avant la classe, puis de simplement utiliser @Inject devant le constructeur de notre objet. Cependant la spécification des arguments dans le constructeur sera nécessaire pour mener à bien nos tests unitaires.

@Singleton
public class UserStorage {

    @Inject Context context;
    @Inject Gson gson;
    @Inject @Named("user") SharedPreferences sharedPreferences;

    @Inject UserStorage(){
    }

}

Dans ce cas il aurait été inutile de définir la méthode provideUserStorage dans MyModule, Dagger2 aurait construit tout seul le singleton UserStorage.

Component

Les Components sont nos injecteurs, ils permettent de récupérer les instances construits par dagger mais aussi de remplir les champs annotés @Inject de nos objets. Afin d’y avoir accès depuis n’importe quel objet, nous avons prit l’habitude de le stocker dans la classe Application de notre projet, qui est la seule classe réellement singletonée de notre projet.

Cet injecteur est annoté @Component et doit connaitre la liste des modules qu’il peux utiliser, ici MyModule.class.

@Singleton
@Component(modules = {MyModule.class})
public interface MyComponent {
    UserStorage userStorage;
    void inject(UserActivity userActivity);
}

Afin de récupérer une instance de cet injecteur, Android Annotations Processing va pré-compiler MyComponent et fournir une classe DaggerMyComponent, que nous pourrons utiliser dans notre code. Afin de récupérer une instance de MyComponent nous pourrons utiliser la méthode suivante

MyComponent component = DaggerMyComponent.create();

MyModule se construisant avec un context, nous allons fournir une référence vers l’application à un Builder de DaggerMyComponent (pour rappel Application extends Context).

public class MyApplication extends Application{

    private static MyApplication INSTANCE;
    MyComponent component;

    @Override
    public void onCreate() {
        super.onCreate();
        INSTANCE = this;

        initComponents();
    }

    public static MyApplication get() {
        return INSTANCE;
    }

    protected void initComponents() {
        component = DaggerMyComponent.builder()
                .myModule(new myModule(this)
                .build();
    }

    public MyComponent component() {
        return myComponent;
    }
}

L’injection du UserStorage dans notre Activity se fera très facilement, ils suffira de créer un champ UserStorage précédé de l’annotation @Inject, puis de récupérer notre component depuis notre application, pour enfin utiliser la méthode inject(this);

public class UserActivity extends Activity{

    @Inject UserStorage userStorage;

    @Bind(R.id.text) TextView textView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_user);
        Butterknife.bind(this);

        MyApplication.get().component().inject(this);
    }

    @OnClick(R.id.login)
    public void displayLoggedUser(){
        User user = userStorage.loadUser(this);
        ... 
    }

}

Tests Unitaires

RoboElectric

Afin de tester les composants Android tel que les Activités, Vues ou Fragments, nous pouvons utiliser RoboElectric. Ce framework permet d’exécuter des tests unitaires depuis la JVM de votre ordinateur, fournissant un context et des mock de services tels que GPS ou Notification Service.

testCompile 'junit:junit:4.12'
testCompile 'org.mockito:mockito-core:1.9.5'
testCompile "org.robolectric:robolectric:3.0"

Retour à Dagger2

Pour nos tests, nous souhaitons remplacer le singleton UserStorage par un mock, pour cela il nous suffit de surcharger la méthode provideUserStorage de MyModule puis retourner Mockito.mock(UserStorage.class)

@Module
public class MyTestModule extends MyModule {

    @Override
    @Provides
    @Singleton
    public UserStorage provideUserStorage(Context context, Gson gson, SharedPreferences sharedPreferences) {
        return Mockito.mock(UserStorage.class);
    }

}

Il faut ensuite créer une classe héritant notre MuApplication, qui va surcharger la méthode initComponents, afin de construire notre component avec un MyTestModule

public class MyTestApplication extends MyApplication{

    @Override
    protected void initComponents() {
        component = DaggerMyApplicationMyComponent.builder()
                .myModule(new MyTestModule(this)
                .build();
    }

}

Test Unitaire

Nous pouvons maintenant écrire notre test unitaire, en spécifiant à RoboElectric d’utiliser notre TestMyApplication, donc utiliser le component précédemment construit avec MyTestModule, qui renverra donc un mock de UserManager.

@RunWith(RobolectricTestRunner.class)
@Config(application = TestMyApplication.class, constants = BuildConfig.class, sdk=21)
public class UserActivityTest {

    UserActivity activity;
    UserStorage userStorage;

    @Before
    public void setUp() throws Exception {
        super.setUp();

        activity = Robolectric.buildActivity(UserActivity.class)
                .create()
                .start()
                .resume()
                .get();

        userStorage = MyApplication.get().component().userStorage();
    }

    @Test
    public void displayLoggedUserShoulDisplayFlorent{
        //Given
        User user = mock(User.class);
        given(user.getName()).willReturn("florent");
    	given(userStorage.loadUser()).willReturn(user);

        //When
        activity.displayLoggedUser();
   
        //Then
        TextView textView = (TextView)activity.findViewById(R.id.text);
    	assertEquals("florent",textView.getText().toString());
    }

}

Une fois cette architecture mise en place, il deviendra très simple d’ajouter des tests unitaires dans tout votre projet, limitant ainsi les crash dues à des cas de régression non vérifiés à chaque nouvelle version de l’application.

Vous aimerez aussi...

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Recevez un ebook GRATUIT !

Nous vous avons créé un ebook pour vous remercier de votre fidélité. Retrouvez les 10 librairies indispensables pour Android. Pour cela rien de plus simple vous avez juste à renseigner votre email, vous recevrez un mail de confirmation (pour que l'on vérifie que vous n'êtes pas un robot), il suffira de vous inscrire à la liste et vous recevrez l'Ebook quelques instant après (peut prendre un petit peu de temps car vous êtes beaucoup à le vouloir).
Votre adresse email
Secure and Spam free...