Pour commencer voici l'architecture de base de la classe:

/// <summary>Manages the download and the installation of the updates related to a specific assembly</summary>
public class AssemblyUpdater
{
	[Inject]
	public AssemblyUpdater(string downloadFolder, string remoteUpdateFilePath)
	{
		Contract.Requires(!string.IsNullOrEmpty(downloadFolder));
		Contract.Requires(!string.IsNullOrEmpty(remoteUpdateFilePath));

		if (!Directory.Exists(downloadFolder))
			throw new DirectoryNotFoundException();

		DownloadFolder = downloadFolder;
		RemoteUpdateFilePath = remoteUpdateFilePath;
	}

	[Inject]
	public IContentFileDownloader ContentFileDownloader { get; set; }

	[Inject]
	public IUpdatesFileContentParser UpdatesFileContentParser { get; set; }

	[Inject]
	public IFileDownloader FileDownloader { get; set; }

	[Inject]
	public IFileChecker FileChecker { get; set; }

	[Inject]
	public IFileInstaller FileInstaller { get; set; }

	public string DownloadFolder { get; private set; }

	public string RemoteUpdateFilePath { get; private set; }

	///<summary>Returns an asynchronous list of available updates for a given set of assemblies</summary>
	public IObservable<AssemblyUpdates> GetUpdates(IEnumerable<Assembly> assemblies)
	{
		Contract.Requires(assemblies != null);
		Contract.Ensures(Contract.Result<IObservable<AssemblyUpdates>>() != null);
	}

	///<summary>Returns each update to download, associated with its downloader</summary>
	public Dictionary<AssemblyUpdateVersion, IObservable<DownloadProgress>> DownloadUpdates()
	{
		Contract.Ensures(Contract.Result<Dictionary<AssemblyUpdateVersion, IObservable<DownloadProgress>>>() != null);
	}

	///<summary>Installs the downloaded updates</summary>
	public IObservable<AssemblyUpdateVersion> InstallUpdates()
	{
		Contract.Requires(downloadedUpdates.Count == (requestedUpdates.SelectMany(update => update.Versions)).Count(), "All the requested updates must have been downloaded");
		Contract.Requires(Contract.ForAll(downloadedUpdates, key => !string.IsNullOrEmpty(key.Value)), "All the requested updates must have their LocalPath set");

		Contract.Ensures(Contract.Result<IObservable<AssemblyUpdateVersion>>() != null);
	}
}

Au niveau des dépendances externes, comme vous pouvez le constater:

  1. une interface pour récupérer le contenu d'un fichier dans lequel se trouvent la liste des mises à jour
  2. une interface qui va parser ce fichier pour en extraire des classes POCO .net
  3. une interface qui va récupérer la mise à jour
  4. une interface de test d'intégrité de la mise à jour récupérée précédemment
  5. une interface pour installer les mises à jour

Pour illustrer mon propos, je ne vais utiliser qu'un seul test, pour montrer la différence entre l'avant et l'après refactorisation du code.

Le test permet de déterminer si la classe testée récupère bien une mise à jour, si dans la liste des adresse où la télécharger se trouve une URL correcte (parmi d'autres qui sont invalides).

Voici le test initial:

[Fact]
public void DownloadUpdates_FiresOnCompleted_WhenAllDownloadUrlsAreInvalidExceptOne()
{
	var au = new AssemblyUpdater(tempFolder, "ValidUrl");

	var contentDownloaderObservable = CreateObservable_ContentFileDownloader_ReturnsValidContent();
	var contentDownloaderStub = CreateMock_IContentFileDownloader("ValidUrl", contentDownloaderObservable);

	var contentParserObservable = CreateObservable_ContentFileParser_ReturnsSoftwareUpdateList(contentDownloaderObservable, new List<AssemblyUpdates>
							{
								new AssemblyUpdates("Emidee.Updater.Tests", new List<AssemblyUpdateVersion> { new AssemblyUpdateVersion("asm", "1.0.0.0", "1.1.0.0", new List<string>
																																								   {
																																									   "InvalidRemoteFilePath", "RemoteFilePath"
																																								   }, "Desc", "Check", 1234) })
							});

	var contentParserStub = CreateMock_IUpdatesFileContentParser("ValidContent", contentParserObservable);

	au.ContentFileDownloader = contentDownloaderStub.Object;
	au.UpdatesFileContentParser = contentParserStub.Object;
	au.FileDownloader = CreateMock_IFileDownloader().Object;

	au.GetUpdates(new List<Assembly> { Assembly.GetAssembly(GetType()) }).Run();

	Exception downloadError = null;
	bool completed = false;

	au.DownloadUpdates()
		.Run(auvd => { },
			error => { downloadError = error; },
		    () => { completed = true; });

	Assert.Null(downloadError);
	Assert.True(completed);
}

On met en place la plomberie du test en créant les différents stubs des interfaces en jeu (via les appels des fonctions CreateObservable_ et CreateMock_), puis seulement à la fin on aperçoit les tests. Pas très lisible donc, puisqu'on doit faire des aller-retours entre les différentes fonctions de création des stubs et le test en cours, pour bien comprendre ce qui se passe, ce que retournent les différentes fonctions, etc...

Voici en gros ce qui se passe lors du test:

  1. Appel de GetUpdates : cette fonction appelle IContentFileDownloader.Download pour récupérer le contenu du fichier puis passe le résultat de cette requête à IUpdatesFileContentParser.GetUpdates, qui va transformer le contenu du fichier en objets .Net
  2. Appel de DownloadUpdates : cette fonction appelle IFileDownloader.DownloadFile sur la première adresse de téléchargement de l'update. Si on ne peut pas télécharger depuis cette adresse (erreur 404 / timeout...), on passe à la suivante, jusqu'à ce qu'on tombe sur une adresse valide, où le téléchargement commence enfin. Si aucune des adresses n'est valide, on envoie une erreur.

Et dans les faits, voici ce que retournent les stubs des interfaces:

  • IContentFileDownloader.Download retourne "ValidContent"
  • IUpdatesFileContentParser.GetUpdates va retourner une update, dont la première url de téléchargement est invalide ("InvalidRemoteFilePath"), et la seconde est correcte ("RemoteFilePath")
  • IFileDownloader.DownloadFile va envoyer une erreur si on lui passe comme url "InvalidRemoteFilePath", et va terminer correctement si on lui donne comme url "RemoteFilePath"

Le test va déterminer qu'on va donc bien passer dans le canal OnCompleted de l'observable malgré l'url invalide, et donc ne pas passer par le canal OnError. (Plus d'informations sur les observable dans des billets ultérieurs)

Pour info, voici la définition de la fonction CreateMock_IFileDownloader, qui montre comment paramétrer Moq pour qu'il agisse différemment selon les paramètres qu'on passe à une fonction:

private static Mock<IFileDownloader> CreateMock_IFileDownloader()
{
	var fd = new Mock<IFileDownloader>(MockBehavior.Strict);

	fd.Setup(f => f.DownloadFile("InvalidRemoteFilePath", It.IsAny<string>(), It.IsAny<bool>()))
		.Returns(Observable.Throw<FileDownloadProgress>(new WebException()));

	fd.Setup(f => f.DownloadFile("RemoteFilePath", It.IsAny<string>(), It.IsAny<bool>()))
		.Returns(Observable.Create<FileDownloadProgress>(observer =>
		{
			string path = Path.Combine(tempFolder, "ValidLocalPath");
		
			observer.OnNext(new FileDownloadProgress(It.IsAny<string>(), DownloadProgress.Empty, path));
			observer.OnCompleted();
			return () => { };
		}));

	return fd;
}

Mais revenons à nos moutons: nous avions donc des tests-unitaires trop long, pas assez lisibles, et trop compliqués à comprendre. Imaginez une trentaine de tests de cette forme, et ça devient vite imbitable.

Pour remédier à ce souci, j'ai externalisé la création des mocks dans une classe helper, et je me suis appuyé sur Ninject pour l'injection de dépendances: chacun des stubs des interfaces va être créé par un provider ninject, et le tout sera utilisé dans la fonction Load d'un module ninject.

Je ne vais pas m'apesentir sur le sujet, le wiki de ninject étant déjà très complet, mais pour utiliser ninject, il faut en premier lieu créer une nouvelle instance de IKernel grâce à la création d'un StandardKernel, auquel on va passer en paramètre des classes dérivant de NinjectModule, qui vont se charger de renseigner Ninject sur les bindings à appliquer sur les interfaces.

Nous allons commencer par créer un module qui permettra à Ninject de créer la classe AssemblyUpdater:

public class AssemblyUpdaterModule : NinjectModule
{
    private string DownloadFolder { get; set; }
    private string Url { get; set; }

    public AssemblyUpdaterModule(string downloadFolder, string url)
    {
        DownloadFolder = downloadFolder;
        Url = url;
    }

    public override void Load()
    {
        Bind<AssemblyUpdater>().ToProvider(new AssemblyUpdaterProvider(DownloadFolder, Url));
    }
}

On passe ici par l'utilisation d'une classe AssemblyUpdaterProvider:

public class AssemblyUpdaterProvider : Provider<AssemblyUpdater>
{
    private string DownloadFolder { get; set; }
    private string RemoteFilePath { get; set; }

    public AssemblyUpdaterProvider(string downloadFolder, string remoteFilePath)
    {
        DownloadFolder = downloadFolder;
        RemoteFilePath = remoteFilePath;
    }

    protected override AssemblyUpdater CreateInstance(IContext context)
    {
        return new AssemblyUpdater(DownloadFolder, RemoteFilePath);
    }
}

Il nous reste à passer ce module en paramètre du StandardKernel dès le début du test, et à demander à Ninject de nous créer une instance de AssemblyUpdater:

[Fact]
public void DownloadUpdates_FiresOnCompleted_WhenAllDownloadUrlsAreInvalidExceptOne()
{
	IKernel kernel = new StandardKernel(new AssemblyUpdaterModule(tempFolder, "Url"));
	
	var au = kernel.Get<AssemblyUpdater>();
	
	// ...
}

Si on lance le test, ça ne fonctionnera pas, car nous n'avons pas dit à Ninject comment instancer les différentes interfaces dont a besoin AssemblyUpdater. C'est là qu'entre en jeu la classe helper fluent:

public class AssemblyUpdaterDependenciesBuilder
{
	private Type ContentFileDownloaderProviderType = typeof (ContentFileDownloaderProvider_ReturnsValidContent);
	private Type UpdatesFileContentParserProviderType = typeof(UpdatesFileContentParserProvider_ReturnsTwoUpdatesMatchingAssemblyNameAndVersion);
	private Type FileDownloaderProviderType = typeof(FileDownloaderProvider_DownloadsOneFile);
	private Type FileCheckerProviderType = typeof(FileCheckerProvider_ReturnsFalse);
	private Type FileInstallerProviderType = typeof(FileInstallerProvider_InstallsOK);

	public AssemblyUpdaterDependenciesModule GetAssemblyUpdaterModule(string assemblyName, string downloadFolder)
	{
		return new AssemblyUpdaterDependenciesModule(assemblyName, downloadFolder, ContentFileDownloaderProviderType, UpdatesFileContentParserProviderType, FileDownloaderProviderType, FileCheckerProviderType, FileInstallerProviderType);
	}
	
	public AssemblyUpdaterDependenciesBuilder SetContentFileDownloaderProviderType<TContentFileDownloaderProvider>()
		where TContentFileDownloaderProvider : ContentFileDownloaderProvider
	{
		ContentFileDownloaderProviderType = typeof(TContentFileDownloaderProvider);
		return this;
	}

	public AssemblyUpdaterDependenciesBuilder SetUpdatesFileContentParserProviderType<TUpdatesFileContentParserProviderType>()
		where TUpdatesFileContentParserProviderType : UpdatesFileContentParserProvider
	{
		UpdatesFileContentParserProviderType = typeof(TUpdatesFileContentParserProviderType);
		return this;
	}

	public AssemblyUpdaterDependenciesBuilder SetFileDownloaderProviderType<TFileDownloaderProviderType>()
		where TFileDownloaderProviderType : FileDownloaderProvider
	{
		FileDownloaderProviderType = typeof (TFileDownloaderProviderType);
		return this;
	}

	public AssemblyUpdaterDependenciesBuilder SetFileCheckerProviderType<TFileCheckerProviderType>()
		where TFileCheckerProviderType : FileCheckerProvider
	{
		FileCheckerProviderType = typeof (TFileCheckerProviderType);
		return this;
	}

	public AssemblyUpdaterDependenciesBuilder SetFileInstallerProviderType<TFileInstallerProviderType>()
		where TFileInstallerProviderType : FileInstallerProvider
	{
		FileInstallerProviderType = typeof (TFileInstallerProviderType);
		return this;
	}
}

C'est long, mais ce n'est pas très compliqué: pour chaque interface qu'on doit implémenter, on définit un provider par défaut qu'utilisera Ninject. Ce provider par défaut créé un mock qui permet à AssemblyUpdater de fonctionner. (Ne lance pas d'exception, retourne les bonnes valeurs, etc...). Si on veut remplacer le provider par défaut d'une interface par un provider personnalisé (qui génère une erreur, retourne un objet invalide, etc...), on passe par les fonctions Set...ProviderType, qui met à jour le type de provider à utiliser, et qui retourne l'instance en cours, ce qui permet de chaîner les appels. Par exemple:

var module = new AssemblyUpdaterDependenciesBuilder()
				.SetFileDownloaderProviderType<FileDownloaderProvider_FiresError<WebException>>()
				.SetFileCheckerProviderType<FileCheckerProvider_ReturnsTrue>()
				.GetAssemblyUpdaterModule("Emidee.Updater.Tests", tempFolder);

Une fois que l'on a défini les différents providers personnalisés à utiliser, on appelle GetAssemblyUpdaterModule, qui va créer et retourner une instance de la classe AssemblyUpdaterDependenciesModule :

public class AssemblyUpdaterDependenciesModule : NinjectModule
{
    private readonly string assemblyName;
    private readonly string downloadFolder;

    private readonly Type contentFileDownloaderProvider;
    private readonly Type updatesFileContentParserProvider;
    private readonly Type fileDownloaderProvider;
    private readonly Type fileCheckerProvider;
    private readonly Type fileInstallerProvider;

    public AssemblyUpdaterDependenciesModule(string assemblyName,
                                    string downloadFolder,
                                    Type TContentFileDownloaderProvider,
                                    Type TUpdatesFileContentParserProvider,
                                    Type TFileDownloaderProvider,
                                    Type TFileCheckerProvider,
                                    Type TFileInstallerProvider)
    {
        this.assemblyName = assemblyName;
        this.downloadFolder = downloadFolder;

        contentFileDownloaderProvider = TContentFileDownloaderProvider;
        updatesFileContentParserProvider = TUpdatesFileContentParserProvider;
        fileDownloaderProvider = TFileDownloaderProvider;
        fileCheckerProvider = TFileCheckerProvider;
        fileInstallerProvider = TFileInstallerProvider;
    }

    public override void Load()
    {
        Bind<IContentFileDownloader>().ToProvider((ContentFileDownloaderProvider)Activator.CreateInstance(contentFileDownloaderProvider));
        Bind<IUpdatesFileContentParser>().ToProvider((UpdatesFileContentParserProvider)Activator.CreateInstance(updatesFileContentParserProvider, assemblyName));
        Bind<IFileDownloader>().ToProvider((FileDownloaderProvider)Activator.CreateInstance(fileDownloaderProvider, downloadFolder));
        Bind<IFileChecker>().ToProvider((FileCheckerProvider)Activator.CreateInstance(fileCheckerProvider));
        Bind<IFileInstaller>().ToProvider((FileInstallerProvider)Activator.CreateInstance(fileInstallerProvider));
    }
}

Rien de bien compliqué ici: on utilise les types des providers reçus en paramètre du constructeur. On créé une instance pour chacun des providers grâce à Activator.CreateInstance, et on les donne à Ninject pour le binding. Il ne nous reste plus qu'à mettre à jour notre test unitaire pour ajouter ce module au kernel Ninject:

[Fact]
public void DownloadUpdates_FiresOnCompleted_WhenAllDownloadUrlsAreInvalidExceptOne()
{
    IKernel kernel = new StandardKernel(new AssemblyUpdaterDependenciesBuilder()
                                            .SetFileDownloaderProviderType<FileDownloaderProvider_ThrowsOnInvalidRemoteFilePathButDownloadWithOtherUrls<WebException>>()
                                            .GetAssemblyUpdaterModule("Emidee.Updater.Tests", tempFolder),
                                        new AssemblyUpdaterModule(tempFolder, "Url"));

    var au = kernel.Get<AssemblyUpdater>();
	
	//...
}

Ici on re-spécifie IFileDownloader pour que le provider soit de type : FileDownloaderProvider_ThrowsOnInvalidRemoteFilePathButDownloadWithOtherUrls<WebException>. Ça fait un long nom de type, mais ça a au moins l'avantage de ne pas avoir à aller regarder le contenu de la classe pour savoir ce qu'elle fait.

Comme vous pouvez le constater, l'écriture des tests est beaucoup plus aisée, puisque n'apparaissent dans le test que les déclarations des interfaces qui ont un impact sur le test (les mocks donc), et sont donc cachées les initialisations des interfaces qui ne l'influencent pas (les stubs). De plus, en créant de nouvelles classes dérivant de chacun des providers de base, vous avez la possibilité de paramétrer chaque test indépendamment les uns des autres, avec une granularité très fine.

Notre test devient donc au final:

[Fact]
public void DownloadUpdates_FiresOnCompleted_WhenAllDownloadUrlsAreInvalidExceptOne()
{
    IKernel kernel = new StandardKernel(new AssemblyUpdaterDependenciesBuilder()
                                            .SetFileDownloaderProviderType<FileDownloaderProvider_ThrowsOnInvalidRemoteFilePathButDownloadWithOtherUrls<WebException>>()
                                            .GetAssemblyUpdaterModule("Emidee.Updater.Tests", tempFolder),
                                        new AssemblyUpdaterModule(tempFolder, "Url"));

    var au = kernel.Get<AssemblyUpdater>();

    au.GetUpdates(new List<Assembly> { Assembly.GetAssembly(GetType()) }).Run();

    Exception downloadError = null;
    bool completed = false;

    au.DownloadUpdates().First().Value.Run(auvd => { },
                                           error => { downloadError = error; },
                                           () => { completed = true; });

    Assert.Null(downloadError);
    Assert.True(completed);
}

Je ne sais pas ce que vous en pensez, mais je trouve la lecture du test beaucoup plus simple, intuitive et compréhensible. Ce qui est bien le but recherché.

Si vous avez des retours à faire sur cet article, n'hésitez pas!