diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f83e8cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +target +*.iml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e1d9dba --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: scala + +dist: trusty + +addons: + sonarcloud: + organization: rafaelspinto-github + token: + secure: j8kq5cQDxJn8qc9b88uSk3LRbJp1fLPx01peRQ6HeYM67BJJ1iXtIg11wXcoPAnpHkAL2+AXeZQIwgLvTyuk1Ri8WQpkMsSid95MiShbm7WP6nYie9vkQRW7BUEXzivaqw8kkMQNWH7/rVsB+i1jq3Ind1UcN1jmX4kysd9bgjt4wPfBcY1q8j3jTNSOTMlWTISvagbUV5nQFeGONLS1bOHqhL5jbvzDE+rpZEv8Ij0H95Rhqym1yCqKKEx7+/iWRDjpX/TZWgtWYRjiiR9s796w+p6RAACd310wGOhbuGs415hU+Y7+hoOEL0QuZ8rRf8r6LJJZx6PbJFhi5R2KEtzpCbDURJP0nCbSNkeP7F4WF3JTCKqlA1KmEfoUax6JlxuDUKyRt3rm78l15Gr9vis7jnsI4V4pkQREEGQcGk2pzkwNlRxGioLDB08eJYi2XiZoamSZ9kXbfY022pRZUYQjaFmQ4GGErzt4maIIFY0AmOCsXONztG6mLAVZF6zM4pjNfzJCtcaWr0AJkr4vQY0xY6SlFQPoxoDOyNCA8HFPKdtVxuAPzFAXHK+4SM9TQgvhEmlbOkPepba7k+wE2FpHHqNQZ+sN3sRhx7BBr3DLD3o1ka+858lQHFBGZKP8EzBV42TPZatnmJdr8PXOtIJ2TZluNxbc3lbJjTHz58I= + +script: + - mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent install sonar:sonar + - mvn test \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba828ac --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# TDD Workshop using Scala + + +This workshop is designed to help you start or improve your [Test Driven Development](https://en.wikipedia.org/wiki/Test-driven_development) skills. + +The [examples](#examples) you will see in this workshop are designed to demonstrate the advantages and technicalities of TDD. The intention is to represent real-world scenarios, however sometimes that will not be possible in favour of simplicity. + + +## What is TDD + +[Test Driven Development](https://en.wikipedia.org/wiki/Test-driven_development) or Test First Development is a process that consists of turning the requirements of the software application into specific test cases (acceptance criteria) and then implement the source code. + +This process uses the **red/green/refactor** pattern and consists of the following steps: + +1. Create Test +2. Run Tests (should fail - Red) +3. Write Code +4. Run Tests (should pass - Green) +5. Refactor + +Repeat + +## Table of contents + +* [Quick start](#quick-start) +* [Testing Tools/Frameworks](#testing-toolsframeworks) +* [Naming conventions](#naming-conventions) +* [AAA Pattern](#aaa-pattern) +* [Mocks & Stubs](#mocks--stubs) +* [Instant Feedback Tools](#instant-feedback-tools) +* [Examples](#examples) + +## Quick start + +Prerequisites + +* You have an IDE or a text editor (e.g.: [IntelliJ IDEA](https://www.jetbrains.com/idea/download)) +* You have [Maven](https://maven.apache.org/) installed + + +## Testing Tools/Frameworks + +We will be using a few tools/frameworks to facilitate our job. + +* [JUnit](https://junit.org/junit4/) - Unit Testing Framework +* [ScalaMock](https://scalamock.org/) - Mocking Framework for Scala +* [ScalaTest](http://www.scalatest.org/) - Unit Testing Framework for Scala. + +## Naming conventions + +Tests serve 3 purposes: + +* [Acceptance Criteria](https://en.wikipedia.org/wiki/Acceptance_testing) - Ensures developed code meets specification +* [Regression Testing](https://en.wikipedia.org/wiki/Regression_testing) - Ensures new changes don't impact previous developed code +* **Documentation** - Demonstrates how the application behaves + +To achieve proper documentation, a good starting point is to create naming conventions for the tests. + +You can define your own conventions keeping in mind that the test methods should clearly identify: + +* What is being tested +* What is the Scenario (Input) +* What should be the outcome (Output) + +Example with a traditional approach (simple JUnit): + +```scala +test("sum: if both numbers are Positive returns positive number") { +... +} +``` + +### AAA Pattern + +Tests typically follow the AAA pattern: + +* **A**rrange - Setup of the Scenario, e.g.: creating the input data +* **A**ct - Executing the action/method +* **A**ssert - Validation of the outcome + +Example: + + +```scala +test("sum: if both numbers are Positive returns a positive number") { + // Arrange + val a = 10 + val b = 20 + val calc = new Calculator() + + // Act + val result = calc.sum(a, b) + + //Assert + assert(result > 0) +} +``` + +## Mocks & Stubs + +[Mocks](https://en.wikipedia.org/wiki/Mock_object) and [Stubs](https://en.wikipedia.org/wiki/Method_stub) are used to facilitate testing by solving the problem of dependencies. + +When the code you are implementing has a dependency, using this technique, you create a fake object that emulates that dependency. If you are required to define specific return values to emulate a certain scenario then you'll need to use a **stub** otherwise you'll simply use a **mock**. + + +Example: + + +* **Mock** + +```scala +var wallet = mock[Wallet] +var provider = mock[Provider] +var broker = new PaymentBroker(wallet, provider) + +``` + +* **Stub** +```scala +var wallet = stub[Wallet] +var provider = stub[Provider] +var broker = new PaymentBroker(wallet, provider) +(wallet.getBalance _).when().returns(balance) +(provider.isAvailable _).when().returns(true) +``` + +## Instant Feedback Tools + +Feedback is one of the most important things in the development world, the sooner you get it the better. + +Typically most of the feedback comes from the user/client of your software, but you should be getting it before you ship it. + +There are plenty of tools out there that can help you with this. In this workshop we will be using the following: + +* **Automation Server** - Allows you to automate the test execution ([Continuous Integration](https://www.thoughtworks.com/continuous-integration)) and other routines associated with it ([Continuous Delivery](https://martinfowler.com/bliki/ContinuousDelivery.html)/[Continuous Deployment](https://www.agilealliance.org/glossary/continuous-deployment/)). In this particular case we are using [Travis CI](https://travis-ci.org/). + +* **Static Code Analyzer** - Allows you to continuously inspect the quality of the code by detecting issues and providing suggestions to solve them. In this project we are using [SonarCloud](http://sonarcloud.io). + +* **Kanban Board** - Allows you to track your project's work with a workflow visualization tool. + + +## Examples + +* [Calculator](/src/test/scala/workshop/calculator) - Simple example to get you started +* [LoginManager](/src/test/scala/workshop/login) - Working with exceptions +* [StringProcessor](/src/test/scala/workshop/strings) - Working with data providers +* [PaymentBroker](/src/test/scala/workshop/payment) - Working with dependencies, mocks and stubs diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d0a1878 --- /dev/null +++ b/pom.xml @@ -0,0 +1,105 @@ + + 4.0.0 + com.rafaelspinto + workshop-tdd-scala + 1.0-SNAPSHOT + ${project.artifactId} + 2018 + + + 1.8 + 1.8 + UTF-8 + 2.12.6 + 2.12 + + + + + org.scala-lang + scala-library + ${scala.version} + + + + + junit + junit + 4.12 + test + + + org.scalatest + scalatest_${scala.compat.version} + 3.0.5 + test + + + + org.scalamock + scalamock-core_${scala.compat.version} + 3.6.0 + + + org.scalamock + scalamock-scalatest-support_${scala.compat.version} + 3.6.0 + test + + + + src/main/scala + src/test/scala + + + + net.alchim31.maven + scala-maven-plugin + 3.4.4 + + + + compile + testCompile + + + + -dependencyfile + ${project.build.directory}/.scala_dependencies + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.21.0 + + + true + + + + + org.scalatest + scalatest-maven-plugin + 2.0.0 + + ${project.build.directory}/surefire-reports + . + TestSuiteReport.txt + + + + test + + test + + + + + + + diff --git a/src/main/scala/workshop/calculator/Calculator.scala b/src/main/scala/workshop/calculator/Calculator.scala new file mode 100644 index 0000000..3d037c6 --- /dev/null +++ b/src/main/scala/workshop/calculator/Calculator.scala @@ -0,0 +1,6 @@ +package workshop.calculator + +class Calculator() { + def sum(a: Int, b: Int) = a + b + +} diff --git a/src/main/scala/workshop/login/InvalidCredentialsException.scala b/src/main/scala/workshop/login/InvalidCredentialsException.scala new file mode 100644 index 0000000..1f85a1a --- /dev/null +++ b/src/main/scala/workshop/login/InvalidCredentialsException.scala @@ -0,0 +1,5 @@ +package workshop.login + +class InvalidCredentialsException extends Throwable{ + +} diff --git a/src/main/scala/workshop/login/LoginManager.scala b/src/main/scala/workshop/login/LoginManager.scala new file mode 100644 index 0000000..7512d12 --- /dev/null +++ b/src/main/scala/workshop/login/LoginManager.scala @@ -0,0 +1,20 @@ +package workshop.login + +import workshop.strings.EmptyPasswordException + +import scala.collection.immutable.HashMap + + +class LoginManager( + val userRepo: HashMap[String, String] + ) { + def login(username: String, password: String): Boolean = { + if (password.isEmpty) { + throw new EmptyPasswordException + } + if (!userRepo.contains(username) || userRepo.get(username) != Some(password)) { + throw new InvalidCredentialsException + } + true + } +} diff --git a/src/main/scala/workshop/payment/InsufficientFundsException.scala b/src/main/scala/workshop/payment/InsufficientFundsException.scala new file mode 100644 index 0000000..3bf6126 --- /dev/null +++ b/src/main/scala/workshop/payment/InsufficientFundsException.scala @@ -0,0 +1,5 @@ +package workshop.payment + +class InsufficientFundsException extends Throwable{ + +} diff --git a/src/main/scala/workshop/payment/PaymentBroker.scala b/src/main/scala/workshop/payment/PaymentBroker.scala new file mode 100644 index 0000000..caf85a8 --- /dev/null +++ b/src/main/scala/workshop/payment/PaymentBroker.scala @@ -0,0 +1,14 @@ +package workshop.payment + +class PaymentBroker(wallet: Wallet, provider: Provider) { + def pay(amount: Int): Boolean = { + if (wallet.getBalance < amount) { + throw new InsufficientFundsException + } + + if (!provider.isAvailable) { + throw new ProviderNotAvailableException + } + true + } +} diff --git a/src/main/scala/workshop/payment/Provider.scala b/src/main/scala/workshop/payment/Provider.scala new file mode 100644 index 0000000..c865c8e --- /dev/null +++ b/src/main/scala/workshop/payment/Provider.scala @@ -0,0 +1,8 @@ +package workshop.payment + +trait Provider { + + def deposit(id: Int, amount: Int): Boolean + + def isAvailable: Boolean +} diff --git a/src/main/scala/workshop/payment/ProviderNotAvailableException.scala b/src/main/scala/workshop/payment/ProviderNotAvailableException.scala new file mode 100644 index 0000000..c8f9752 --- /dev/null +++ b/src/main/scala/workshop/payment/ProviderNotAvailableException.scala @@ -0,0 +1,5 @@ +package workshop.payment + +class ProviderNotAvailableException extends Throwable { + +} diff --git a/src/main/scala/workshop/payment/Wallet.scala b/src/main/scala/workshop/payment/Wallet.scala new file mode 100644 index 0000000..b284e73 --- /dev/null +++ b/src/main/scala/workshop/payment/Wallet.scala @@ -0,0 +1,8 @@ +package workshop.payment + +trait Wallet { + + def getId(): Int = 1234 + + def getBalance: Int +} diff --git a/src/main/scala/workshop/strings/EmptyPasswordException.scala b/src/main/scala/workshop/strings/EmptyPasswordException.scala new file mode 100644 index 0000000..ccd5cf2 --- /dev/null +++ b/src/main/scala/workshop/strings/EmptyPasswordException.scala @@ -0,0 +1,5 @@ +package workshop.strings + +class EmptyPasswordException extends Throwable{ + +} diff --git a/src/main/scala/workshop/strings/EmptyStringException.scala b/src/main/scala/workshop/strings/EmptyStringException.scala new file mode 100644 index 0000000..1f30f7a --- /dev/null +++ b/src/main/scala/workshop/strings/EmptyStringException.scala @@ -0,0 +1,5 @@ +package workshop.strings + +class EmptyStringException extends Throwable { + +} diff --git a/src/main/scala/workshop/strings/StringProcessor.scala b/src/main/scala/workshop/strings/StringProcessor.scala new file mode 100644 index 0000000..db1291a --- /dev/null +++ b/src/main/scala/workshop/strings/StringProcessor.scala @@ -0,0 +1,15 @@ +package workshop.strings + +class StringProcessor() { + def countVowels(str: String) = { + if (str.isEmpty) { + throw new EmptyStringException + } + var count = 0 + val vowels = "aeiouAEIOU" + for (char <- str.toCharArray) { + if (vowels.contains(char)) count += 1 + } + count + } +} diff --git a/src/test/scala/workshop/calculator/CalculatorTest.scala b/src/test/scala/workshop/calculator/CalculatorTest.scala new file mode 100644 index 0000000..f8620c7 --- /dev/null +++ b/src/test/scala/workshop/calculator/CalculatorTest.scala @@ -0,0 +1,38 @@ +package workshop.calculator + +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner + +@RunWith(classOf[JUnitRunner]) +class CalculatorTest extends FunSuite { + val calc = new Calculator() + + + test("sum: if both numbers are Positive returns a positive number") { + // Arrange + val a = 10 + val b = 20 + + // Act + val result = calc.sum(a, b) + + //Assert + assert(result > 0) + } + + test("sum: if both numbers are negative returns a negative number") { + val a = -10 + val b = -20 + + assert(calc.sum(a, b) < 0) + + } + + test("sum: if numbers are opposite returns zero") { + val a = -10 + val b = 10 + + assert(calc.sum(a, b) == 0) + } +} diff --git a/src/test/scala/workshop/calculator/README.md b/src/test/scala/workshop/calculator/README.md new file mode 100644 index 0000000..3710fb3 --- /dev/null +++ b/src/test/scala/workshop/calculator/README.md @@ -0,0 +1,17 @@ +# Calculator + +Implement a calculator that supports the sum operation. + + +## Acceptance criteria + +### With traditional approach (simple JUnit) + +* if numbers are positive the result should be positive. +* if numbers are negative the result should be negative. +* if numbers are opposite the result should be zero. + +## Uses + +* [JUnit](https://junit.org/junit4/) +* [ScalaTest](http://www.scalatest.org/) \ No newline at end of file diff --git a/src/test/scala/workshop/login/LoginManagerTest.scala b/src/test/scala/workshop/login/LoginManagerTest.scala new file mode 100644 index 0000000..9da1866 --- /dev/null +++ b/src/test/scala/workshop/login/LoginManagerTest.scala @@ -0,0 +1,36 @@ +package workshop.login + +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner +import workshop.strings.EmptyPasswordException + +import scala.collection.immutable.HashMap + +@RunWith(classOf[JUnitRunner]) +class LoginManagerTest extends FunSuite { + + val userRepo: HashMap[String, String] = HashMap(("myuser", "mypassword")) + val loginManager = new LoginManager(userRepo) + + test("login: if credentials are valid returns true") { + val username = "myuser" + val password = "mypassword" + assert(loginManager.login(username, password)) + } + + test("login: credentials are invalid throws InvalidCredentialsException") { + val username = "myuser" + val password = "invalidpassword" + assertThrows[InvalidCredentialsException] { + loginManager.login(username, password) + } + } + + test("login: if password is empty throws EmptyPasswordException") { + val username = "myuser" + assertThrows[EmptyPasswordException] { + loginManager.login(username, "") + } + } +} diff --git a/src/test/scala/workshop/login/README.md b/src/test/scala/workshop/login/README.md new file mode 100644 index 0000000..f407b5c --- /dev/null +++ b/src/test/scala/workshop/login/README.md @@ -0,0 +1,15 @@ +# Login Manager + +Implement a login manager that supports the login operation. + + +## Acceptance Criteria + +* if credentials are valid should return true. +* if credentials are invalid should throw InvalidCredentialsException. +* if password is empty should throw EmptyPasswordException. + +## Uses + +* [JUnit](https://junit.org/junit4/) +* [ScalaTest](http://www.scalatest.org/) \ No newline at end of file diff --git a/src/test/scala/workshop/payment/PaymentBrokerTest.scala b/src/test/scala/workshop/payment/PaymentBrokerTest.scala new file mode 100644 index 0000000..66b7890 --- /dev/null +++ b/src/test/scala/workshop/payment/PaymentBrokerTest.scala @@ -0,0 +1,51 @@ +package workshop.payment + +import org.junit.runner.RunWith +import org.scalamock.scalatest.MockFactory +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner + +@RunWith(classOf[JUnitRunner]) +class PaymentBrokerTest extends FunSuite with MockFactory { + + var wallet = stub[Wallet] + var provider = stub[Provider] + var broker = new PaymentBroker(wallet, provider) + + test("pay: if wallet has funds and provider is available and deposit succeeded returns true") { + val amount = 10 + val balance = 10 + + (wallet.getBalance _).when().returns(balance) + (provider.isAvailable _).when().returns(true) + (provider.deposit _).when(wallet.getId(), amount).returns(true) + + assert(broker.pay(amount)) + } + + test("pay: if wallet does not have funds throws InsufficientFundsException") { + val amount = 10 + val balance = 0 + + (wallet.getBalance _).when().returns(balance) + (provider.isAvailable _).when().returns(true) + (provider.deposit _).when(wallet.getId(), amount).returns(true) + + assertThrows[InsufficientFundsException] { + broker.pay(amount) + } + } + + test("pay: if provider is not available throws ProviderNotAvailableException") { + val amount = 10 + val balance = 20 + + (wallet.getBalance _).when().returns(balance) + (provider.isAvailable _).when().returns(false) + (provider.deposit _).when(wallet.getId(), amount).returns(true) + + assertThrows[ProviderNotAvailableException] { + broker.pay(amount) + } + } +} \ No newline at end of file diff --git a/src/test/scala/workshop/payment/README.md b/src/test/scala/workshop/payment/README.md new file mode 100644 index 0000000..910c413 --- /dev/null +++ b/src/test/scala/workshop/payment/README.md @@ -0,0 +1,17 @@ +# Payment Broker + +Implement a payment broker that supports the pay operation with a wallet and a payment provider. + + +## Acceptance Criteria + +* if wallet has funds and provider is available and deposit succeeds should return true. +* if wallet does not have funds should throw InsufficientFundsException. +* if provider is not available should throw ProviderNotAvailableException. + + +## Uses + +* [JUnit](https://junit.org/junit4/) +* [ScalaTest](http://www.scalatest.org/) +* [ScalaMock](https://scalamock.org/) \ No newline at end of file diff --git a/src/test/scala/workshop/strings/README.md b/src/test/scala/workshop/strings/README.md new file mode 100644 index 0000000..681bbea --- /dev/null +++ b/src/test/scala/workshop/strings/README.md @@ -0,0 +1,18 @@ +# String Processor + +Implement a string processor that allows to do operations with strings. + + +## Acceptance Criteria + +### countVowels + +* if vowels exist should return the number of vowels. +* if vowels don't exist should return 0. +* if string is empty should throw EmptyStringException. + + +## Uses + +* [JUnit](https://junit.org/junit4/) +* [ScalaTest](http://www.scalatest.org/) \ No newline at end of file diff --git a/src/test/scala/workshop/strings/StringProcessorTest.scala b/src/test/scala/workshop/strings/StringProcessorTest.scala new file mode 100644 index 0000000..d096e11 --- /dev/null +++ b/src/test/scala/workshop/strings/StringProcessorTest.scala @@ -0,0 +1,36 @@ +package workshop.strings + +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner +import org.scalatest.prop.TableDrivenPropertyChecks + +@RunWith(classOf[JUnitRunner]) +class StringProcessorTest extends FunSuite with TableDrivenPropertyChecks { + + val stringProc = new StringProcessor() + + val dataWithVowels = Table( + ("word", "expected"), + ("abcd", 1), + ("abcdde", 2) + ) + + val dataWithoutVowels = Table( + ("wrd"), + ("bcd"), + ("bcdd") + ) + + test("countVowels: if vowels exist returns number of vowels") { + forAll(dataWithVowels) { (word: String, expected: Int) => + assert(stringProc.countVowels(word) == expected) + } + } + + test("countVowels: if vowels dont exist returns 0") { + forAll(dataWithoutVowels) { (word: String) => + assert(stringProc.countVowels(word) == 0) + } + } +}