Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

Writing Good Tests

Se guardiamo oltre la meccanica, il TDD è una tecnica complessa perché richiede molteplici skill. Da principiante dopo l’implementazione di poche storie ti imbatti nel problema dei test che si rompono ad ogni refactoring, è arrivato il momento di migliorare i propri skill di scrittura dei test. Nel talk analizzeremo la struttura dei test, quali sono le bad smell più comuni e come porvi rimedio.

Writing Good Tests

  1. 1. WRITING GOOD TESTS @matteobaglini #IAD14
  2. 2. TEST-DRIVEN DEVELOPMENT
  3. 3. @iacoware
  4. 4. TDD MECHANICS
  5. 5. TDD IS A MULTIFACETED SKILL
  6. 6. SKILL ON CLEAN CODE
  7. 7. SKILL ON REFACTORING SAFELY
  8. 8. SKILL ON DISCOVER TEST CASES
  9. 9. SKILL ON CHOOSE NEXT TESTS
  10. 10. SKILL ON WRITING TESTS
  11. 11. SKILL ON WRITING GOOD TESTS
  12. 12. FOCUS ON WRITING GOOD TESTS
  13. 13. PROPERTIES
  14. 14. TESTS MUST BE ISOLATED
  15. 15. TESTS MUST BE INDIPENDENT
  16. 16. TESTS MUST BE DETERMINISTIC
  17. 17. AFFECT EXECUTION
  18. 18. WHAT ABOUT TEST CODE?
  19. 19. BAD CODE SMELLS
  20. 20. BAD TEST CODE SMELLS
  21. 21. LEAD TO FRAGILITY ON REFACTORING
  22. 22. WHY SHOULD WE CARE?
  23. 23. WHAT‘S AGILE PRACTICES’ ULTIMATE GOAL?
  24. 24. REDUCE THE COST AND RISK OF CHANGE
  25. 25. FRAGILE TESTS INCREASE COSTS
  26. 26. FRAGILE TESTS LOSS OF CONFIDENCE
  27. 27. FRAGILE TESTS REDUCE SUSTAINABILITY
  28. 28. PAY OFF YOUR TECHNICAL DEBT
  29. 29. WHERE IS COMPLEXITY?
  30. 30. [Fact] public void ScenarioUnderTest() { // Arrange // Act // Assert }
  31. 31. START FROM ASSERT
  32. 32. 01 COMPARING OBJECTS
  33. 33. [Fact] public void Subtract() { var one = TimeRange.Parse("09:00-10:00"); var two = TimeRange.Parse("09:15-09:45"); var result = one.Subtract(two); Assert.Equal(2, result.Length); Assert.Equal(TimeSpan.Parse("09:00"), result[0].Begin); Assert.Equal(TimeSpan.Parse("09:15"), result[0].End); Assert.Equal(TimeSpan.Parse("09:45"), result[1].Begin); Assert.Equal(TimeSpan.Parse("10:00"), result[1].End); }
  34. 34. [Fact] public void Subtract() { var one = TimeRange.Parse("09:00-10:00"); var two = TimeRange.Parse("09:15-09:45"); var result = one.Subtract(two); Assert.Equal(2, result.Length); Assert.Equal(TimeSpan.Parse("09:00"), result[0].Begin); Assert.Equal(TimeSpan.Parse("09:15"), result[0].End); Assert.Equal(TimeSpan.Parse("09:45"), result[1].Begin); Assert.Equal(TimeSpan.Parse("10:00"), result[1].End); }
  35. 35. WHICH KIND OF OBJECTS?
  36. 36. VALUE OBJECTS
  37. 37. VALUE OBJECTS MODEL FIXED QUANTITIES
  38. 38. VALUE OBJECTS IMMUTABLE
  39. 39. VALUE OBJECTS NO IDENTITY
  40. 40. VALUE OBJECTS EQUAL IF THEY HAVE SAME STATE
  41. 41. [Fact] public void Subtract() { var one = TimeRange.Parse("09:00-10:00"); var two = TimeRange.Parse("09:15-09:45"); var result = one.Subtract(two); Assert.Equal(2, result.Length); Assert.Equal(TimeSpan.Parse("09:00"), result[0].Begin); Assert.Equal(TimeSpan.Parse("09:15"), result[0].End); Assert.Equal(TimeSpan.Parse("09:45"), result[1].Begin); Assert.Equal(TimeSpan.Parse("10:00"), result[1].End); }
  42. 42. [Fact] public void Equality() { var slot = TimeRange.Parse("09:00-10:00"); var same = TimeRange.Parse("09:00-10:00"); var differentBegin = TimeRange.Parse("08:00-10:00"); var differentEnd = TimeRange.Parse("09:00-13:00"); Assert.Equal(slot, same); Assert.NotEqual(slot, differentBegin); Assert.NotEqual(slot, differentEnd); }
  43. 43. [Fact] public void Subtract() { var one = TimeRange.Parse("09:00-10:00"); var two = TimeRange.Parse("09:15-09:45"); var result = one.Subtract(two); Assert.Equal(2, result.Length); Assert.Equal(TimeSpan.Parse("09:00"), result[0].Begin); Assert.Equal(TimeSpan.Parse("09:15"), result[0].End); Assert.Equal(TimeSpan.Parse("09:45"), result[1].Begin); Assert.Equal(TimeSpan.Parse("10:00"), result[1].End); }
  44. 44. [Fact] public void Subtract() { var one = TimeRange.Parse("09:00-10:00"); var two = TimeRange.Parse("09:15-09:45"); var result = one.Subtract(two); Assert.Contains(TimeRange.Parse("09:00-09:15"), result); Assert.Contains(TimeRange.Parse("09:45-10:00"), result); }
  45. 45. OBJECTS
  46. 46. OBJECTS MODEL STATE AND PROCESSING OVER TIME
  47. 47. OBJECTS MUTABLE
  48. 48. OBJECTS HAS IDENTITY
  49. 49. OBJECTS EQUAL IF THEY HAVE SAME IDENTITY
  50. 50. [Fact] public void GetAllMessages() { var messageId1 = Guid.NewGuid(); var messageId2 = Guid.NewGuid(); var store = new MessageStore(); store.Add(DummyMessage(messageId1)); store.Add(DummyMessage(messageId2)); var result = store.GetAll(); Assert.Equal(2, result.Count); Assert.NotNull(result.Single(x => x.Id == messageId1)); Assert.NotNull(result.Single(x => x.Id == messageId2)); }
  51. 51. [Fact] public void GetAllMessages() { var messageId1 = Guid.NewGuid(); var messageId2 = Guid.NewGuid(); var store = new MessageStore(); store.Add(DummyMessage(messageId1)); store.Add(DummyMessage(messageId2)); var result = store.GetAll(); Assert.Equal(2, result.Count); Assert.NotNull(result.Single(x => x.Id == messageId1)); Assert.NotNull(result.Single(x => x.Id == messageId2)); }
  52. 52. [Fact] public void Equality() { var message = DummyMessage(Guid.NewGuid()); var same = DummyMessage(message.Id); var diffent = DummyMessage(Guid.NewGuid()); Assert.Equal(message, same); Assert.NotEqual(message, diffent); }
  53. 53. [Fact] public void GetAllMessages() { var messageId1 = Guid.NewGuid(); var messageId2 = Guid.NewGuid(); var store = new MessageStore(); store.Add(DummyMessage(messageId1)); store.Add(DummyMessage(messageId2)); var result = store.GetAll(); Assert.Equal(2, result.Count); Assert.NotNull(result.Single(x => x.Id == messageId1)); Assert.NotNull(result.Single(x => x.Id == messageId2)); }
  54. 54. [Fact] public void GetAllMessages() { var messageId1 = Guid.NewGuid(); var messageId2 = Guid.NewGuid(); var store = new MessageStore(); store.Add(DummyMessage(messageId1)); store.Add(DummyMessage(messageId2)); var result = store.GetAll(); Assert.Contains(DummyMessage(messageId1), result); Assert.Contains(DummyMessage(messageId2), result); }
  55. 55. WHEN YOU CAN’T CHANGE THE OBJECT?
  56. 56. [Fact] public void GetContactsDetails() { var controller = new ContactsController(); var result = controller.Details(Guid.NewGuid()); var viewResult = Assert.IsType<ViewResult>(result); Assert.Equal(String.Empty, viewResult.ViewName); Assert.IsAssignableFrom<ContactModel>(viewResult.Model); }
  57. 57. [Fact] public void GetContactsDetails() { var controller = new ContactsController(); var result = controller.Details(Guid.NewGuid()); var viewResult = Assert.IsType<ViewResult>(result); Assert.Equal(String.Empty, viewResult.ViewName); Assert.IsAssignableFrom<ContactModel>(viewResult.Model); }
  58. 58. [Fact] public void GetContactsDetails() { var controller = new ContactsController(); var result = controller.Details(Guid.NewGuid()); MvcAssert.IsViewResult<ContactModel>(result); }
  59. 59. 02 TESTING ONLY ONE CONCERN
  60. 60. [Fact] public void SingleElement() { var input = "<element />"; var tag = Tag.Parse(input); Assert.True(tag.IsEmpty()); Assert.Equal("element", tag.Name()); Assert.Empty(tag.ChildNodes()); }
  61. 61. [Fact] public void SingleElement() { var input = "<element />"; var tag = Tag.Parse(input); Assert.True(tag.IsEmpty()); Assert.Equal("element", tag.Name()); Assert.Empty(tag.ChildNodes()); }
  62. 62. TEST CAN FAIL FOR TOO MANY REASONS
  63. 63. [Fact] public void SingleElement() { var input = "<element />"; var tag = Tag.Parse(input); Assert.True(tag.IsEmpty()); Assert.Equal("element", tag.Name()); Assert.Empty(tag.ChildNodes()); }
  64. 64. [Fact] public void SingleElementNoContent() { var input = "<element />"; var tag = Tag.Parse(input); Assert.True(tag.IsEmpty()); }
  65. 65. [Fact] public void SingleElementName() { var input = "<element />"; var tag = Tag.Parse(input); Assert.Equal("element", tag.Name()); }
  66. 66. [Fact] public void SingleElementNoChildren() { var input = "<element />"; var tag = Tag.Parse(input); Assert.Empty(tag.ChildNodes()); }
  67. 67. 03 TESTING BEHAVIOUR
  68. 68. STATE VS BEHAVIOUR
  69. 69. FOCUS ON TESTING STATE
  70. 70. [Fact] public void NewStackIsEmpty() { var stack = new Stack(); Assert.True(stack.IsEmpty()); }
  71. 71. [Fact] public void StackIsNotEmptyAfterAddItem() { var stack = new Stack(); stack.Push(5); Assert.False(stack.IsEmpty()); }
  72. 72. [Fact] public void StackCountIncreaseAfterAddItems() { var items = new[] { 1, 2, 3 }; var stack = new Stack(); stack.Push(items[0]); stack.Push(items[1]); stack.Push(items[2]); Assert.Equal(items.Length, stack.Count()); }
  73. 73. FOCUS ON TESTING BEHAVIOUR
  74. 74. [Fact] public void PopOneItem() { var item = 5; var stack = new Stack(); stack.Push(item); Assert.Equal(item, stack.Pop()); }
  75. 75. [Fact] public void PopManyItems() { var items = new[] { 1, 2, 3 }; var stack = new Stack(); stack.Push(items[0]); stack.Push(items[1]); stack.Push(items[2]); Assert.Equal(items.Reverse(), new[] { stack.Pop(), stack.Pop(), stack.Pop() }); }
  76. 76. [Fact] public void PopEmptyStack() { var stack = new Stack(); Assert .Throws<InvalidOperationException>(() => stack.Pop()); }
  77. 77. 04 AVOID TESTING PRIVATE METHODS
  78. 78. public class Invoice { // ... public Eur Total() { var gross = ComputeGrossAmount(/* args */); var tax = ComputeTaxAmount(/* args */); return gross.Add(tax); } }
  79. 79. public class Invoice { // ... Eur ComputeGrossAmount(/* args */) { /* many lines of complex * and commented code */ } }
  80. 80. public class Invoice { // ... Eur ComputeTaxAmount(/* args */) { /* many lines of complex * and commented code */ } }
  81. 81. [Fact] public void GrossAmountCalculation() { // We want invoke private ComputeGrossAmount method } [Fact] public void TaxAmountCalculation() { // We want invoke private ComputeTaxAmount method }
  82. 82. DON’T DO IT!
  83. 83. DON’T DO IT!
  84. 84. DON’T DO IT!
  85. 85. CONSIDER APPLYING METHOD OBJECT
  86. 86. MAKE AN OBJECT OUT OF THE METHOD
  87. 87. public class Invoice { // ... public Eur Total() { var gross = new GrossAmount(/* args */).Compute(); var tax = new TaxAmount(/* args */).Compute(); return gross.Add(tax); } }
  88. 88. NOW TEST EXTRACTED OBJECTS
  89. 89. 05 SAME LEVEL OF ABSTRACTION
  90. 90. [Fact] public void SetCommand() { var cache = new Cache(); var dispatcher = new CommandDispatcher(cache); dispatcher.Process("SET foo bar"); var actual = cache.Get("foo"); Assert.Equal("bar", actual); }
  91. 91. [Fact] public void SetCommand() { var cache = new Cache(); var dispatcher = new CommandDispatcher(cache); dispatcher.Process("SET foo bar"); var actual = cache.Get("foo"); Assert.Equal("bar", actual); }
  92. 92. ACT AT HIGHER ASSERT AT LOWER
  93. 93. REMEMBER DIP?
  94. 94. DON’T CROSS THE STREAM
  95. 95. [Fact] public void SetCommand() { var cache = new Cache(); var dispatcher = new CommandDispatcher(cache); dispatcher.Process("SET foo bar"); var actual = cache.Get("foo"); Assert.Equal("bar", actual); }
  96. 96. [Fact] public void SetCommand() { var cache = new Cache(); var dispatcher = new CommandDispatcher(cache); dispatcher.Process("SET foo bar"); var actual = dispatcher.Process("GET foo"); Assert.Equal("bar", actual); }
  97. 97. WHEN IT ISN’T YOUR RESPONSIBILITY?
  98. 98. [Fact] public void SetCommand() { var log = new TransactionLog(); var cache = new PersistentCache(log); cache.Set("foo", "bar"); var written = log.History().Take(1).Single(); Assert.Equal("SET foo barrn", written); }
  99. 99. [Fact] public void SetCommand() { var log = new TransactionLog(); var cache = new PersistentCache(log); cache.Set("foo", "bar"); var written = log.History().Take(1).Single(); Assert.Equal("SET foo barrn", written); }
  100. 100. PURE FABRICATION
  101. 101. [Fact] public void SetCommand() { var log = new TransactionLog(); var cache = new PersistentCache(log); cache.Set("foo", "bar"); var written = log.History().Take(1).Single(); Assert.Equal("SET foo barrn", written); }
  102. 102. [Fact] public void SetCommand() { var log = new TransactionLog(); var cache = new PersistentCache(log); cache.Set("foo", "bar"); var written = log.History().Take(1).Single(); Assert.Equal("SET foo barrn", written); }
  103. 103. [Fact] public void SetCommand() { var log = new TransactionLog(); var cache = new PersistentCache(log); var monitor = new Monitor(log); cache.Set("foo", "bar"); var written = log.History().Take(1).Single(); Assert.Equal("SET foo barrn", written); }
  104. 104. [Fact] public void SetCommand() { var log = new TransactionLog(); var cache = new PersistentCache(log); var monitor = new Monitor(log); cache.Set("foo", "bar"); var written = monitor.LastLog(); Assert.Equal("SET foo barrn", written); }
  105. 105. WHEN SIDE EFFECTS AREN’T DIRECTLY RELATED?
  106. 106. [Fact] public void CommandStatistics() { var channel = new StatisticsChannel(); var cache = new Cache(channel); var dashboard = new RealTimeDashboard(channel); cache.Set("foo", "bar"); var received = dashboard.LastReceived(); Assert.Equal("NAME: SET; ARGS: foo bar", received); }
  107. 107. [Fact] public void CommandStatistics() { var channel = new StatisticsChannel(); var cache = new Cache(channel); var dashboard = new RealTimeDashboard(channel); cache.Set("foo", "bar"); var received = dashboard.LastReceived(); Assert.Equal("NAME: SET; ARGS: foo bar", received); }
  108. 108. BREAK THE FLOW
  109. 109. OBSERVER PLUS SELF SHUNT
  110. 110. public interface ICacheSubscriber { void NotifyCommandExecuted(string name, string args); }
  111. 111. public class CacheTests : ICacheSubscriber { string ExecutedCommandName; string ExecutedCommandArgs; public void NotifyCommandExecuted(string name, string args) { ExecutedCommandName = name; ExecutedCommandArgs = args; } // ... }
  112. 112. [Fact] public void CommandStatistics() { var channel = new StatisticsChannel(); var cache = new Cache(channel); var dashboard = new RealTimeDashboard(channel); cache.Set("foo", "bar"); var received = dashboard.LastReceived(); Assert.Equal("NAME: SET; ARGS: foo bar", received); }
  113. 113. [Fact] public void CommandStatistics() { var cache = new Cache(channel); cache.Set("foo", "bar"); Assert.Equal("NAME: SET; ARGS: foo bar", received); }
  114. 114. [Fact] public void CommandStatistics() { var cache = new Cache(channel); cache.Set("foo", "bar"); Assert.Equal("NAME: SET; ARGS: foo bar", received); }
  115. 115. [Fact] public void CommandStatistics() { ICacheSubscriber subscriber = this; var cache = new Cache(subscriber); cache.Set("foo", "bar"); Assert.Equal("NAME: SET; ARGS: foo bar", received); }
  116. 116. [Fact] public void NotifyCommandExecution() { ICacheSubscriber subscriber = this; var cache = new Cache(subscriber); cache.Set("foo", "bar"); Assert.Equal("SET", this.ExecutedCommandName); Assert.Equal("foo bar", this.ExecutedCommandArgs); }
  117. 117. 06 RESPECT ABSTRACTION
  118. 118. [Fact] public void Overcrowding() { var moreThanThreeNeighbors = 6; var cell = new Cell(alive: true); var alive = cell.StayAlive(moreThanThreeNeighbors); Assert.False(alive); }
  119. 119. [Fact] public void Overcrowding() { var moreThanThreeNeighbors = 6; var cell = new Cell(alive: true); var alive = cell.StayAlive(moreThanThreeNeighbors); Assert.False(alive); }
  120. 120. SEGREGATE DECISIONS
  121. 121. [Fact] public void Overcrowding() { var moreThanThreeNeighbors = 6; var cell = new Cell(alive: true); var alive = cell.StayAlive(moreThanThreeNeighbors); Assert.False(alive); }
  122. 122. [Fact] public void Overcrowding() { var moreThanThreeNeighbors = 6; var cell = Cell.Live(); var alive = cell.StayAlive(moreThanThreeNeighbors); Assert.False(alive); }
  123. 123. INFORMATION HIDING
  124. 124. 07 COMPLEX OBJECT CREATION
  125. 125. [Fact] public void OrderCost() { var order = new Order(Location.TakeAway, new OrderLines( new OrderLine("Latte", new Quantity(1), Size.Small, new Pounds(1, 50)), new OrderLine("Cappuccino", new Quantity(2), Size.Large, new Pounds(2, 50)))); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  126. 126. [Fact] public void OrderCost() { var order = new Order(Location.TakeAway, new OrderLines( new OrderLine("Latte", new Quantity(1), Size.Small, new Pounds(1, 50)), new OrderLine("Cappuccino", new Quantity(2), Size.Large, new Pounds(2, 50)))); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  127. 127. EXTRACT FACTORY METHOD
  128. 128. [Fact] public void OrderCost() { var order = CreateOrderWithLatteAndCappuccino(); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  129. 129. OBJECT MOTHER
  130. 130. [Fact] public void OrderCost() { var order = TestOrders .CreateOrderWithLatteAndCappuccino(); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  131. 131. Order CreateEmptyOrder() { /* ... */ }
  132. 132. Order CreateEmptyOrder() { /* ... */ } Order CreateOrderWithLatteAndCappuccino() { /* ... */ }
  133. 133. Order CreateEmptyOrder() { /* ... */ } Order CreateOrderWithLatteAndCappuccino() { /* ... */ } Order CreateOrderWithLatteMultipleQuantities() { /* ... */ }
  134. 134. Order CreateEmptyOrder() { /* ... */ } Order CreateOrderWithLatteAndCappuccino() { /* ... */ } Order CreateOrderWithLatteMultipleQuantities() { /* ... */ } Order CreateOrderWithCaffeSpecialDiscount() { /* ... */ }
  135. 135. POOR NAME
  136. 136. LOSS OF CONTEXT
  137. 137. DOESN’T SCALE
  138. 138. COMMON SHARED FIXTURE
  139. 139. [Fact] public void OrderCost() { var order = CreateCommonOrder(); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  140. 140. [Fact] public void DiscountedOrderCost() { var order = CreateCommonOrder(); order.Add(Discounts.ByQuantity(2)); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  141. 141. [Fact] public void DiscountedOrderCost() { var order = CreateCommonOrder(); order.Add(Discounts.ByQuantity(2)); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  142. 142. SAME FAILURE
  143. 143. [Fact] public void OrderCost() { var order = new Order(Location.TakeAway, new OrderLines( new OrderLine("Latte", new Quantity(1), Size.Small, new Pounds(1, 50)), new OrderLine("Cappuccino", new Quantity(2), Size.Large, new Pounds(2, 50)))); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  144. 144. DECOUPLE WHAT IS NEEDED
  145. 145. FROM HOW IT IS STRUCTURED
  146. 146. BUILDER PATTERN
  147. 147. [Fact] public void OrderCost() { var order = new Order(Location.TakeAway, new OrderLines( new OrderLine("Latte", new Quantity(1), Size.Small, new Pounds(1, 50)), new OrderLine("Cappuccino", new Quantity(2), Size.Large, new Pounds(2, 50)))); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  148. 148. [Fact] public void OrderCost() { var order = new OrderBuilder() .WithLocation(Location.TakeAway) .WithLine(new OrderLineBuilder() .WithName("Latte") .WithPrice(new Pounds(1, 50))) .WithLine(new OrderLineBuilder() .WithName("Cappuccino") .WithQuantity(new Quantity(2)) .WithSize(Size.Large) .WithPrice(new Pounds(1, 50))); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  149. 149. STILL EXPOSE STRUCTURE
  150. 150. INTRODUCE HIGHER LEVEL METHODS
  151. 151. [Fact] public void OrderCost() { var order = new OrderBuilder() .WithLocation(Location.TakeAway) .WithLine(new OrderLineBuilder() .WithName("Latte") .WithPrice(new Pounds(1, 50))) .WithLine(new OrderLineBuilder() .WithName("Cappuccino") .WithQuantity(new Quantity(2)) .WithSize(Size.Large) .WithPrice(new Pounds(1, 50))); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  152. 152. [Fact] public void OrderCost() { var order = new OrderBuilder() .TakeAway() .WithOne("Latte", "1,50") .WithTwoLarge("Cappuccino", "1,50"); var result = order.Cost(); Assert.Equal(new Pounds(6, 50), result); }
  153. 153. DOMAIN SPECIFIC LANGUAGE
  154. 154. RECAP
  155. 155. STOP THE MADNESS
  156. 156. TEST CODE NEED LOVE
  157. 157. LISTEN TO TEST CODE
  158. 158. TOO MUCH INTIMACY
  159. 159. EXPRESS INTENT
  160. 160. FOCUS ON WHAT
  161. 161. NOT ON HOW
  162. 162. REMOVE DUPLICATION
  163. 163. INTRODUCE ABSTRACTIONS
  164. 164. RESPECT ABSTRACTION LEVELS
  165. 165. EVOLVE TEST INFRASTRUCTURE
  166. 166. BABY STEPS
  167. 167. CONTINUOUS REFACTORING
  168. 168. IMPROVE YOUR SKILLS
  169. 169. MATTEO BAGLINI FREELANCE SOFTWARE DEVELOPER TECHNICAL COACH @matteobaglini
  170. 170. https://joind.in/talk/view/12770

×