Jak każdemu porządnemu developerowi zdarza mi się czasem napisać testy. Jak każdemu porządnemu developerowi, czasem zdarza mi się wykorzystać mechanizm metod rozszerzających (jeśli nie wiesz o czym mówię sprawdź na msdn). Jak każdy prawdziwy developer, chciałem przetestować logikę, która była wykorzystywana w jednej z takich metod. W zasadzie to nie w samej metodzie, chciałem sprawdzić czy zostanie wywołana z wartościami, które są dla mnie ważne.
Zacznę od metod rozszerzających, a testowanie przyjdzie samo.
Tak wygląda interfejs udostępniony przez samą (bez metod rozszerzających) klasę List:
Aby przewinąć listę na sam dół wystarczą trzy wciśnięcia Page Down. Jak widać jedyny import, z którego korzystam to System.Collections.Generic, wymagany aby móc skorzystać z List.
To się stanie gdy dodam System.Linq: (obrazek się troszkę nie mieści)
Pojawi się nowa funkcjonalność, zdefiniowana w System.Linq, która pozwala robić cuda z listą i innymi IEnumerable (jak również innymi obiektami). Po imporcie aby przewinąć listę metod, trzeba osiem razy walnąć w Page Down.
Co zrobić, aby lista nowych funkcjonalności nie zaśmiecała dostępnego api? Można to wszystko ukryć przy pomocy jednej klasy, która przekierowuje żądania dalej, do właściwej części, gdzie zdefiniowane będą rozszerzenia
Normalnie implementacja rozszerzeń może wyglądać jakoś tak:
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: Consolas, “Courier New”, Courier, Monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #a31515; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
1: namespace ConsoleApplication
2: {
3:
4: class Program
5: {
6: static void Main(string[] args)
7: {
8: MyClass mc = new MyClass();
9: }
10: }
11:
12: public static class MyClassExtension
13: {
14: public static void Foo0(this MyClass target) { }
15: public static void Foo1(this MyClass target) { }
16: public static void Foo2(this MyClass target) { }
17: public static void Foo3(this MyClass target) { }
18: public static void Foo4(this MyClass target) { }
19: public static void Foo5(this MyClass target) { }
20: public static void Foo6(this MyClass target) { }
21: public static void Foo7(this MyClass target) { }
22: public static void Foo8(this MyClass target) { }
23: public static void Foo9(this MyClass target) { }
24: }
25:
26: public class MyClass
27: {
28: }
29: }
Po takiej implementacji otrzymujemy cztery metody podstawowe, oraz dodatkowy dziesięć nowych z rozszerzenia w MyClassExtension.
Można trochę to wszystko uprościć dodając odpowiednią warstwę abstrakcji (abstrakcja was uwolni, zapamiętajcie to). Opis poniżej:
1: namespace ConsoleApplication
2: {
3: using System;
4:
5: class Program
6: {
7: static void Main(string[] args)
8: {
9: MyClass mc = new MyClass();
10: mc.ExtensionService().Foo0();
11: }
12: }
13:
14: public static class MyClassExtension
15: {
16: public static Func<MyClass, IMyClassExtensionService> ExtensionFactory { get; set; }
17:
18: static MyClassExtension()
19: {
20: ExtensionFactory = target=> new MyClassExtensionReleaseVersion(target);
21: }
22:
23: public static IMyClassExtensionService ExtensionService(this MyClass target)
24: {
25: return ExtensionFactory(target);
26: }
27: }
28:
29: public interface IMyClassExtensionService
30: {
31: void Foo0();
32: void Foo1();
33: void Foo2();
34: void Foo3();
35: void Foo4();
36: void Foo5();
37: void Foo6();
38: void Foo7();
39: void Foo8();
40: void Foo9();
41: }
42:
43: public class MyClassExtensionReleaseVersion : IMyClassExtensionService
44: {
45: private MyClass myclass;
46:
47: public MyClassExtensionReleaseVersion(MyClass target)
48: {
49: this.myclass = target;
50: }
51:
52: public void Foo0() { }
53: public void Foo1() { }
54: public void Foo2() { }
55: public void Foo3() { }
56: public void Foo4() { }
57: public void Foo5() { }
58: public void Foo6() { }
59: public void Foo7() { }
60: public void Foo8() { }
61: public void Foo9() { }
62: }
63:
64: public class MyClass
65: {
66: }
67: }
Tak się to prezentuje teraz:
Zacznę od linijki 29 gdzie zdefiniowany został interfejs ze wszystkimi metodami, które mają działać jako rozszerzenie dla klasy MyClass. Linia 43 to implementacja tego interfejsu, warto zauważyć że klasa ta nie jest statyczna, oraz nie posiada statycznych pól, a metody nie przyjmują parametrów. W konstruktorze otrzymuje ona obiekt, na rzecz którego ma działać. Najlepsze zostawiam na koniec, linijka 14, klasa udostępniająca rozszerzenie dla MyClass. W ExtensionService (23) wykorzystywana jest właściwość ExtensionFactory, który jest zwyczajną metodą wytwórczą, zwracającą nowy obiekt MyClassExtensionReleaseVersion, do którego przesyła obiekt, na rzecz którego ma zostać wywołana metoda rozszerzająca. Mam nadzieję, że kod tłumaczy to prościej niż ja. Linia 10 to nowy sposób na wywołanie metod rozszerzających.
Właściwość ExtensionFactory została zdefiniowana jako publiczna, co umożliwia jego zmianę w razie potrzeby testowania. Tak samo obiekt rozszerzający jest deklarowany poprzez interfejs, w związku z czym w testach można wykorzystać np. MyClassExtensionMockVersion i sprawdzać czy klasa zachowuje się tak jak tego oczekujemy.
Ja skorzystałem z tego rozwiązania podczas testowania nawigacji w projekcie, który korzysta z PRISMa, tam do zmiany widoków wykorzystywany jest interfejs IRegionManager i kilka metod rozszerzających ten interfejs. Chciałem sprawdzić, czy po wykonaniu operacji X jedna z moich klas zarząda zmiany widoku. Jedynym sposobem, na sprawdzenie parametrów przesłanych w query było stworzenie i podstawienie własnej implementacji metod rozszerzających. Na początku testów mam taki zapis:
1: Capture.Common.Prism.PrismExtensions.ServiceFactory = p => new NavigationServiceMock(p);
Tak wygląda mój lipny serwis, który działa zamiast tego z Prism:
1: public class NavigationServiceMock : INavigation
2: {
3: private IRegionManager p;
4:
5: public NavigationServiceMock(IRegionManager p)
6: {
7: this.p = p;
8: }
9:
10: public IRegionManager AddToRegion(string regionName, object view)
11: {
12: throw new NotImplementedException();
13: }
14:
15: public IRegionManager RegisterViewWithRegion(string regionName, Func<object> getContentDelegate)
16: {
17: throw new NotImplementedException();
18: }
19:
20: public IRegionManager RegisterViewWithRegion(string regionName, Type viewType)
21: {
22: throw new NotImplementedException();
23: }
24:
25: public void RequestNavigate(string regionName, string source)
26: {
27: throw new NotImplementedException();
28: }
29:
30: public void RequestNavigate(string regionName, Uri source)
31: {
32: LoadTradeViewModelTest.NavigationServiceMock_RequestedNavigatedTargetRegionName = regionName;
33: LoadTradeViewModelTest.NavigationServiceMock_ReqestedNavigatedTargetUri = source.OriginalString;
34: }
35:
36: public void RequestNavigate(string regionName, string source, Action<NavigationResult> navigationCallback)
37: {
38: throw new NotImplementedException();
39: }
40:
41: public void RequestNavigate(string regionName, Uri source, Action<NavigationResult> navigationCallback)
42: {
43: throw new NotImplementedException();
44: }
45: }
Najważniejsze w tym wszystkim to RequestNavigate (30) gdzie zapisuje żądany region i uri. Później w teście porównuje czy są takie jak oczekiwałem. Wszystkie NotImplementedException są nie używane w testach, dlatego też mogły pozostać w takiej domyślnie oferowanej przez VS formie.
Normalna implementacja przekazuje wszystkie parametry do oryginalnej implementacji.
Pojawia się pewien problem, przy próbie stworzenie podobnego rozwiązania dla klas generycznych. Ten problem to kompilator, który cały czas twierdzi że metody rozszerzające muszą być w klasach statycznych, nie generycznych. Same metody mogą być generyczne. Aby to obejść na szybko wymyśliłem coś takiego
1: namespace ConsoleApplication
2: {
3: using System;
4: using System.Collections.Generic;
5:
6: class Program
7: {
8: static void Main(string[] args)
9: {
10: List<int> l = new List<int>();
11: l.GetExtension().Foo1();
12: }
13: }
14:
15: public static class ExtensionsFactory<T>
16: {
17: public static Func<List<T>, IListExtensionService> Factory { get; set; }
18: }
19:
20: public static class ListExtension
21: {
22: public static IListExtensionService GetExtension<T>(this List<T> target)
23: {
24: if (ExtensionsFactory<T>.Factory == null)
25: {
26: ExtensionsFactory<T>.Factory = o => new ListExtensionSerivce<T>(o);
27: }
28:
29: return ExtensionsFactory<T>.Factory(target);
30: }
31: }
32:
33: public interface IListExtensionService
34: {
35: void Foo1();
36: }
37:
38: public class ListExtensionSerivce<T> : IListExtensionService
39: {
40: private List<T> list;
41:
42: public ListExtensionSerivce(List<T> target)
43: {
44: this.list = target;
45: }
46:
47: public void Foo1()
48: {
49: Console.WriteLine("foo and the list");
50: }
51: }
52: }
Cały trik w tym przypadku, to przeniesienie odpowiedzialności na wytwarzanie obiektów z ListExtension do ExtensionsFactory. A podczas wywołania metody, należny się upewnić że fabryka już istnieje.
Myślę że wystarczy mojego wymądrzania się na dziś. Podoba się? Nie podoba się? Macie inne sposoby? Pytania? Uwagi? Cokolwiek?
ps
Mam nadzieję, że więcej wyjaśniłem niż zakręciłem.
ps2
Jak już wszystko napiszę, to czytam czy jest ok, a potem i tak mam lekkiego stresa, że coś głupiego napisałem.