Letöltés mappa rendező program – 4. rész
A programunk működését jelen szoftver esetén egység- és rendszer szinten fogjuk tesztelni. Integrációs tesztszintnek jelen program esetén önmagában nincs értelme a komponensek egyszer használtsága és kis száma miatt. A rendszerszintű tesztelés manuális tesztelést fog jelenteni.
Unit teszt futtatásra a Visual Studio tartalmaz egy teszt futtató környezetet, de teszt íráshoz nem tartalmaz ilyet. Ennek oka az, hogy .NET esetén jelen pillanatban 3db teszt keretrendszer közül tudunk választani. A .NET-tel egy idős az MSTest keretrendszer, amit nem meglepő módon a Microsoft fejlesztett. Ez a Visual Studio Community előtti időszakban csak a fizetős Visual Studio-val volt használható, így nem mindenkinek volt hozzáférése.
Éppen ezért jött létre az NUnit projekt, ami a Java-ban íródott JUnit keretrendszer .NET-es megfelelője. Mivel a JUnit keretrendszert sok Java programozó is ismerte, nem meglepő módon hamarosan fizetős programokat gyártó cégek is elkezdték alkalmazni tesztelésre.
A harmadik keretrendszer az xUnit.net. Ezt eredetileg a NUnit készítői írták. A létrehozásának fő oka az volt, hogy a C# fejlődésével új és jobb nyelvi elemek kerültek bevezetésre, amik egyszerűsítették volna a teszt írást. Viszont ezt nem igen tudták beépíteni a NUnit kódba, ezért létrehoztak egy bővíthető, alapjaiban C#-ra és .NET-re tervezett egységteszt rendszert.
Mivel három keretrendszer közül is tudunk választani, kérdés, hogy melyiket válasszuk? Jelen program esetén NUnit-ot fogunk használni, mivel ennek a működésével vagyok a leginkább tisztában, de ugyanezeket a teszteket megírhatnánk MSTest és xUnit.net segítségével is. Eltérés a megvalósításban lenne. A teszt keretrendszer választását minden projekt esetén érdemes mérlegelni: ha a projekten a legtöbb ember az NUnit-ot ismeri kézreállóan, akkor nem érdemes xUnit vagy MSTest alapokon elkezdeni a tesztelést, mivel csak frusztrációt fog okozni a fejlesztőkben és ez meg fog látszani a tesztek minőségén. Ha új keretrendszert szeretnénk kipróbálni, akkor azt kisebb, hobbi projekteken érdemes elkezdeni.
Előkészületek
Unit tesztek írásához létre kell hoznunk egy új projektet és be kell állítanunk. Ezt megtehetjük Visual Studio-ból is, de erre a célra kifejezetten a .NET CLI-t szeretem használni, mert gyorsabb tud lenni, mint Visual Studio-ban végigkattintgatni. A SLN fájlunkat tartalmazó mappából az alábbi parancsokat adjuk ki:
mkdir DloadOrganizer.Tests
cd DloadOrganizer.Tests
dotnet new nunit
dotnet add package Moq
dotnet add DloadOrganizer.Tests.csproj reference ..\DloadOrganizer\DloadOrganizer.csproj
cd ..
dotnet sln add DloadOrganizer.Tests\DloadOrganizer.Tests.csproj
A fennti parancsok létrehozzák a DloadOrganizer.Tests projektet, ami rendelkezik az NUnit használatához szükséges összes referenciával, valamint az SLN fájlunk is tartalmazni fogja az új projektet.
ConfigurationManager tesztjei
Az első komponens, amit tesztelni tudunk, az a ConfigurationManager. Ennek a láthatósága internal, vagyis csak a programon belül látható. Tehát külső programból nem tudjuk elérni, csak akkor, ha megengedjük. Ehhez hozzunk létre egy új üres cs fájlt Test.cs néven a DloadOrganizer projektben. A fájl tartalma a következő legyen:
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("DloadOrganizer.Tests")]
Az InternalsVisibleTo attribútum a megadott nevű szerelvény számára elérhetővé teszi az internal láthatóságú elemeket úgy, mintha azok publikusak lennének.
Ezután létrehozhatjuk a ConfigurationManager osztály tesztjeit:
using NUnit.Framework;
using System.IO;
namespace DloadOrganizer.Tests
{
[TestFixture]
public class ConfigurationManagerTests
{
private ConfigurationManager _sut;
[SetUp]
public void Setup()
{
//arrange
_sut = new ConfigurationManager();
}
[TearDown]
public void TearDown()
{
if (File.Exists(Path.Combine(_sut.AppDir, "DloadOrganizerConfig.json")))
{
File.Delete(Path.Combine(_sut.AppDir, "DloadOrganizerConfig.json"));
}
}
[Test]
public void WriteConfigWorks()
{
//act
_sut.WriteExampleConfig();
//assert
Assert.IsTrue(_sut.IsConfigExisting);
}
[Test]
public void ReadConfigWorks()
{
//act
_sut.WriteExampleConfig();
var config = _sut.ReadConfigurationFile();
//assert
Assert.IsNotNull(config.SourceDirectory);
Assert.IsNotNull(config.Rules);
}
}
}
Az NUnit teszt keretrendszer a TestFixture attribútummal megjelölt osztályokban keres teszteket, amiket futtatni tud majd. Az egyes metódusok előtt a Test attribútum jelöli, hogy az egy teszt.
A SetUp attribútummal megjelölt metódus minden teszt előtt lefuttatásra kerül, míg a TearDown attribútummal megjelölt metódus minden teszt után kerül lefuttatásra.
Ezekben a metódusokban hozzuk létre és bontjuk le a teszt környezetet. A SetUp és a TearDown használata nem kötelező, de nagymértékben átláthatóbbá teszik a teszteket.
A teszteknek a 3A mintát érdemes követjük: Arrange, Act, Assert. Az arrange lépés a környezet létrehozása, az act a művelet végzése és végezetül az assert az ellenőrzés.
A tesztelés alatt álló komponenst _sut vagy SUT változónévvel szoktuk jelölni, ami a System Under Test (Tesztelés alatt álló rendszer) szavak rövidítése.
Jelen komponens esetén két metódust érdemes tesztelni. Ez a WriteExampleConfig, ami létrehoz egy alap konfigurációt és a ReadConfigurationFile, ami beolvassa.
A konfig létrehozási teszt csak azt nézi, hogy a fájl létrejött-e. A beolvasó teszt azt ellenőrzi, hogy nem kapunk vissza null értéket, ha a fájl létezik és a megfelelő mezők ki vannak töltve.
A teszteket parancssorból az alábbi parancs kiadásával tudjuk futtatni:
dotnet test
Visual Studio-ban pedig a Test menü alatt található Test Explorer ablakból tudjuk futtatni őket.
Ha lefuttatjuk most őket, akkor nagy meglepetésünkre bukott állapotban lesz mind a két teszt. A teszt futtatása közben kapott üzenet nagy segítségünkre van:
System.InvalidOperationException : Process was not started by this object, so requested information cannot be determined.
TearDown : System.NullReferenceException : Object reference not set to an instance of an object.
A hiba a ConfigurationManager konstruktorában keresendő, ahol meghatározzuk az AppDir Property értékét:
AppDir = Path.GetDirectoryName(Process.GetCurrentProcess().StartInfo.FileName) ?? Environment.CurrentDirectory;
A Process objektum csak akkor enged hozzáférést az indítási adatokhoz, ha a kódot tartalmazó osztály indította el a folyamatot. Ez egy biztonsági korlátozás, vagyis ezzel a módszerrel nem határozhatjuk meg az indítási mappát.
Ezért módosítsuk így:
AppDir = AppContext.BaseDirectory;
A módosítás után, ha futtatjuk a teszteket, akkor láthatjuk, hogy a tesztek zöldre futnak, vagyis a komponensünk jól működik.
ConfigurationValidator tesztjei
using DloadOrganizer.Configuration;
using NUnit.Framework;
using System.Collections.Generic;
using System.Linq;
namespace DloadOrganizer.Tests
{
[TestFixture]
internal class ConfigurationValidatorTests
{
public Config ValidConfig;
private ConfigurationValidator _sut;
[SetUp]
public void Setup()
{
_sut = new ConfigurationValidator();
ValidConfig = new Config
{
SourceDirectory = "c:\\",
Rules = new Rule[]
{
new Rule
{
Patterns = new string[] { ".txt" },
TargetDirectory = "c:\\"
}
}
};
}
[Test]
public void TestValidConfig()
{
//act
bool result = _sut.IsValid(ValidConfig);
//assert
Assert.IsFalse(_sut.Errors.Any());
Assert.IsTrue(result);
}
[TestCase(null)]
[TestCase("")]
[TestCase("z:\\asd\\foo")]
public void TestSourceDir(string dir)
{
//arrange
ValidConfig.SourceDirectory = dir;
//act
bool result = _sut.IsValid(ValidConfig);
//assert
Assert.IsFalse(result);
Assert.IsTrue(_sut.Errors.Any());
}
internal static IEnumerable<Rule> InvalidRules
{
get
{
yield return new Rule
{
TargetDirectory = null,
Patterns = new string[] { ".txt" }
};
yield return new Rule
{
TargetDirectory = "",
Patterns = new string[] { ".txt" }
};
yield return new Rule
{
TargetDirectory = "z:\\adgft\\wqg3g",
Patterns = new string[] { ".txt" }
};
yield return new Rule
{
TargetDirectory = "c:\\",
Patterns = null,
};
yield return new Rule
{
TargetDirectory = "c:\\",
Patterns = new string[0],
};
}
}
[TestCaseSource(nameof(InvalidRules))]
public void TestRules(Rule rule)
{
ValidConfig.Rules[0] = rule;
//act
bool result = _sut.IsValid(ValidConfig);
//assert
Assert.IsFalse(result);
Assert.IsTrue(_sut.Errors.Any());
}
[Test]
public void TestNoRules()
{
ValidConfig.Rules = null;
//act
bool result = _sut.IsValid(ValidConfig);
//assert
Assert.IsFalse(result);
Assert.IsTrue(_sut.Errors.Any());
}
}
}
A ConfigurationValidator osztály tesztjei ellenőrzik a validáció működését. A Setup minden teszt előtt létrehoz egy érvényes konfigurációt, aminek egy tulajdonságát elrontunk és megnézzük, hogy reagál-e rá a rendszer megfelelően.
Nagy meglepetésünkre a TestValidConfig teszt hibára fog futni. Ez egy elírásnak köszönhető a ConfigurationValidator osztályban:
else if (config.Rules.Any(r => Directory.Exists(r.TargetDirectory)))
{
_errors.Add(Resources.ValidationRuleTargetNotExist);
}
Mint látható, lemaradt a feltétel invertálása. Helyesen:
else if (config.Rules.Any(r => !Directory.Exists(r.TargetDirectory)))
{
_errors.Add(Resources.ValidationRuleTargetNotExist);
}
A TestCase attribútum paraméteres tesztek létrehozását engedi meg, amivel elkerülhetjük a teszt kód duplikálását. A TestSourceDir metódus a SourceDir tulajdonság nem megengedett értékeit teszteli sorban.
A TestCase hátránya, hogy csak olyan értékeket tudunk vele paraméternek átadni, amelyek már fordítási időben is létrehozhatóak. Ez az esetek többségében megfelel, viszont ha objektumokat akarunk kompletten cserélgetni, akkor a TestCaseSource attribútumot kell használnunk. Ez egy statikus IEnumerable<T> felsorolásból tud teszteseteket futtatni.
A Rule típusok validációjára ezt használtuk fel. Az InvalidRules tulajdonság az összes nem valid Rule leírást tartalmazza.
Viszont az is elképzelhető, hogy maga a Rule felsorolás null értéket vesz fel. Ezt a TestNoRules teszteli.
A TestNoRules futtatásánál és a TestRules esetén egy-egy esetben NullReferenceException hibával elszáll a tesztek futtatása. Ez annak köszönhető, hogy két helyen is elfelejtettünk null értékre ellenőrizni.
A Rules lehet null és bármelyik Rule Patterns tulajdonsága is felvehet null értéket. Ezért az IsValid metódus működését ki kell egészítenünk a megfelelő null ellenőrzésekkel:
public bool IsValid(Config config)
{
_errors.Clear();
if (string.IsNullOrEmpty(config.SourceDirectory))
{
_errors.Add(Resources.ValidationNoSourceDir);
}
else if (!Directory.Exists(config.SourceDirectory))
{
_errors.Add(Resources.ValidationSourcetDirNotExist);
}
if (config.Rules == null
|| config.Rules.Length < 1)
{
_errors.Add(Resources.ValidationNoRules);
return false;
}
else if (config.Rules.Any(r => string.IsNullOrEmpty(r.TargetDirectory)))
{
_errors.Add(Resources.ValidationRuleNoTarget);
}
else if (config.Rules.Any(r => !Directory.Exists(r.TargetDirectory)))
{
_errors.Add(Resources.ValidationRuleTargetNotExist);
}
else if (config.Rules.Any(r => r.Patterns == null
|| r.Patterns.Length == 0))
{
_errors.Add(Resources.ValidationRuleNoExtensions);
}
return _errors.Count == 0;
}
Nem tesztelt osztályok
Az előző cikkben utaltam rá, hogy nem biztos, hogy a 100%-os lefedettségre szükség van. Jelen projekt esetén a FileSystem és ProgramConsole osztályok tesztelése kimaradt.
Ennek az oka igen egyszerű. Ez a két osztály minimális logikát tartalmaz, illetve tipikus példái a glue kódnak. Glue kódnak jellemzően olyan kódot nevezünk, ami kell a program működéséhez, de nem járul hozzá semmilyen funkcionalitáshoz és a program követelményeinek való megfeleléshez.
Ha a fennti definíciónak nem is felel meg az említett két osztály, akkor egy másik érv a nem tesztelésük mellett az, hogy ezen két osztály 90% már eleve tesztelt a keretrendszer készítői által, így nincs értelme még 1x letesztelnünk a képernyőre írást, illetve a fájl másolást, mivel ezt más megtette már helyettünk.
Organizer tesztjei
A programunk fő osztálya az Organizer, ami rendelkezik pár függőséggel. Mivel a programot az Interface Segregation és a Dependency Inversion elveket betartva készítettük el, ezért ez az osztály viszonylag könnyen tesztelhető.
Az interfész függőségeit kicserélhetnénk egy teszt környezet implementációra. Ha ezt az utat választjuk, akkor minden függőségnek létre kell hoznunk egy Stub, magyarul csonk osztályt, ami a teszt környezetben úgy viselkedik ahogy szeretnénk.
Ennek a megoldásnak a hátránya, hogy ezek elkészítése időigényes. Éppen ezért itt is azt a módszert alkalmazzuk, mint amit a nagyvilágban szokás: a felületeket Mock objektumokkal helyettesítjük. A Mock egy szimulált objektum, ami vezérelt módon utánozza a valós objektum viselkedését.
C# esetén számos ilyen megoldás létezik. A legnépszerűbb a Moq könyvtár. Ez belsőleg reflection segítségével generál egy dinamikus proxy osztályt, ami az előre meghatározott módon fog viselkedni.
A Moq könyvtár fő típusa a Mock<T>, amiben a T típus paraméter helyére interfészeket és absztrakt osztályokat tudunk helyezni. A Mock<T> létrehozásakor a konstruktornak opcionálisan megadhatunk a MockBehavior felsorolásból értéket. Ez lehet Loose (alapértelmezett) vagy Strict.
Utóbbi esetén, ha a mockolt osztályon vagy a felületen egy korábban nem definiált viselkedésű metódust vagy tulajdonságot hívunk fel, akkor a teszt el fog bukni. Ez különösen hasznos olyan függőségek mockolásánál, ami esetén a teszt szempontjából fontos hívás történik.
Az alábbi teszt kódban a IConsole Mock létrehozásakor használtuk csak a Loose működést. Ennek oka szimplán az, hogy a konkrét teszteseteink szempontjából nem releváns, hogy mit is ír ki pontosan a képernyőre a program, ezért csak plusz munka lett volna a hívásokat megfelelően beállítani.
Az Organizer osztály tesztjei:
using DloadOrganizer.Interfaces;
using Moq;
using NUnit.Framework;
using System.Linq;
namespace DloadOrganizer.Tests
{
[TestFixture]
public class OrganizerTests
{
private Organizer _sut;
private Mock<IConsole> _consoleMock;
private Mock<IConfigurationManager> _configManagerMock;
private Mock<IConfigurationValidator> _configValidatorMock;
private Mock<IFileSystem> _filesystemMock;
private Configuration.Config _config;
private const string _sourceDir = "test:\\dir";
private const string _targetDir = "test:\\dir2";
private readonly string[] _files = new[] { "test:\\dir\\file.tst" };
[SetUp]
public void Setup()
{
_consoleMock = new Mock<IConsole>(MockBehavior.Loose);
_configManagerMock = new Mock<IConfigurationManager>(MockBehavior.Strict);
_configValidatorMock = new Mock<IConfigurationValidator>(MockBehavior.Strict);
_filesystemMock = new Mock<IFileSystem>(MockBehavior.Strict);
_config = new Configuration.Config
{
SourceDirectory = _sourceDir,
Rules = new Configuration.Rule[]
{
new Configuration.Rule
{
Patterns = new string[] { ".tst" },
TargetDirectory = _targetDir,
}
}
};
_configManagerMock.Setup(x => x.WriteExampleConfig());
_configManagerMock.SetupGet(x => x.IsConfigExisting).Returns(true);
_configManagerMock.Setup(x => x.ReadConfigurationFile()).Returns(_config);
_configValidatorMock.Setup(x => x.IsValid(_config)).Returns(true);
_filesystemMock.Setup(x => x.GetFiles(_sourceDir)).Returns(_files);
_filesystemMock.Setup(x => x.Move(It.IsAny<string>(), It.IsAny<string>()));
_sut = new Organizer(_consoleMock.Object,
_configManagerMock.Object,
_configValidatorMock.Object,
_filesystemMock.Object);
}
[Test]
public void LoadConfigExistsAppIfConfigNotValid()
{
//arrange
_configValidatorMock.Setup(x => x.IsValid(_config)).Returns(false);
_configValidatorMock.SetupGet(x => x.Errors).Returns(Enumerable.Empty<string>);
//act
_sut.LoadConfig();
//assert
_consoleMock.Verify(x => x.PressKeyAndExit(), Times.Once);
}
[Test]
public void LoadConfigWritesConfigAndExistsIfNotExisting()
{
//arrange
_configManagerMock.SetupGet(x => x.IsConfigExisting).Returns(false);
//act
_sut.LoadConfig();
//assert
_configManagerMock.Verify(x => x.WriteExampleConfig(), Times.Once);
_consoleMock.Verify(x => x.PressKeyAndExit(), Times.Once);
}
[Test]
public void RunWorks()
{
//arrange
_sut.LoadConfig();
//act
_sut.Run();
//assert
_filesystemMock.Verify(x => x.Move("test:\\dir\\file.tst", "test:\\dir2\\file.tst"), Times.Once);
}
}
}
Ennek a tesztnek a Setup() metódusa kicsivel vaskosabbra sikeredett. Ez annak köszönhető, hogy a Mock osztályok beállítása is itt történik meg. A Mock osztályunkon a Setup metódus meghívásával egy lambda metóduson keresztül konfigurálhatjuk a szimulálni kívánt metódus viselkedését. Ha a konfigurált metódus rendelkezik visszatérési értékkel, akkor a Returns metódushívással tudunk visszatérési értéket adni. A SetupGet lényegében ugyanezt csinálja, csak ez tulajdonságok beállítására szolgál.
De mi van akkor, ha a teszt szempontjából nem lényeges egy paraméter konkrét értéke? Ebben az esetben a paraméter helyére az It.IsAny<T> helyettesíthető. Ha egy metódus paramétere ezzel van konfigurálva, akkor a teszt szempontjából nem fog számítani annak a konkrét paraméternek az értéke.
Ez a tesztben a _filesystemMock Move metódusának konfigurálásakor van használva. Ennek oka itt az, hogy konkrét teszt esetben ellenőrizzük, hogy a megfelelő paraméterekkel történt-e a meghívása. Ha lenne 80db hívás erre a metódusra különböző kombinációkkal, akkor bizony It.IsAny<T> használata nélkül mind a 80 esetet fel kellene sorolnunk, ami drámaian rontana a teszt olvashatóságán és karbantarthatóságán. A Mock osztályok a tesztelés alatt álló komponensbe az Object tulajdonságuk segítségével illeszthetőek be.
A teszt osztály a következő eseteket fedi le:
- A
LoadConfig()hívásakor a programnak ki kell lépnie, ha a konfiguráció hibás - A
LoadConfig()hívásakor a programnak ki kell lépnie, miután létrehozott egy példa konfigurációt, ha a konfigurációs fájl nem létezik. - A
Run()hívásakor a szabályoknak megfelelő másolás történik.
Ezen feltételek teljesülése a Mock osztályokon a Verify metódus meghívásával ellenőrizhetőek. A Verify hívása a legtöbb esetben két argumentumot fog tartalmazni. Elsőként egy lambda metódust, ami leírja, hogy konkrétan melyik metódus hívását ellenőrizzük milyen paraméterekkel. Ha egy konkrét paraméter nem számít, akkor itt is az It.IsAny<T> helyettesítésre használható. A második paraméter pedig a hívások száma lesz, amit a Times osztállyal tudunk meghatározni.
Meglepő módon az OrganizerTests osztály összes tesztje hibára fog futni. Ez annak köszönhető, hogy a legtöbb interfészünk internal módosítót kapott. Mivel a Moq reflection segítségével generál egy proxy osztályt, ezért láthatóvá kell tennünk ezeket a típusokat a kódgenerátorának az InternalsVisibleTo attribútummal, amit a hibaüzenetben ki is ír. Ez alapján módosítottam a Test.cs fájl tartalmát:
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("DloadOrganizer.Tests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
Lefedettség követése
A lefedettséget azért érdemes követni, mert kiderülhet a segítségével, hogy egy komplex és fontos metódusnál milyen tesztesetek maradtak ki a tesztelésből. A Visual Studio Community változata ezt sajnos beépítetten nem tudja, mivel ez egy olyan szolgáltatás, ami csak a fizetős változatokban található meg. De mivel a Studio Community változata is bővíthető Pluginek segítségével, ezért a megfelelő beépülő telepítésével hozzájutunk ehhez az információhoz. Én erre a célra a Fine Code Coverage beépülőt használom, ami teszt futtatás után generál egy statisztikát, de ami igazán hasznos, hogy a kódban a margón pirosan jelzi azon sorokat, amik nincsenek lefedve egy teszt által sem.


Végszó
A Letöltés mappa rendező programunk elkészült és valamilyen szinten tesztelve is van. A tesztek számán és minőségén biztos lehetne javítani vagy esetleg átírni xUnit rendszerre, de így is elég hosszúra nyúlt már ez a cikk. A kódot a GitHub oldalunkon belül találjátok meg. A link: https://github.com/CsharptutorialHungary/LetoltesMapparendezo
A cikksorozattal és egyéb írásainkkal kapcsolatban általában elérhetőek szoktunk lenni a Discord szerverünkön is munka után: https://discord.com/invite/vpypa9QmCw
2021.03.26. @ 16:54
Szia!
Az előkészületeknél nekem nem működtek a parancsok, csak a következő módon:
mkdir DloadOrganizer.Tests
cd DloadOrganizer.Tests
dotnet new nunit
dotnet add package Moq
dotnet add DloadOrganizer.Tests.csproj reference ..\DloadOrganizer\DloadOrganizer.csproj
//!!! innentől változás
cd ..\DloadOrganizer\
dotnet sln add ..\DloadOrganizer.Tests\DloadOrganizer.Tests.csproj
Tehát átléptem a DloadOrganizer sln fájlt tartalmazó mappába és onnan hivatkoztam a teszt projekt fájljára.