Skip to content

πŸ§ͺ An example of Acceptance Test-Driven Development of a Shiny app using {shinytest2} and {selenider}

Notifications You must be signed in to change notification settings

jakubsob/shiny-acceptance-tdd

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

25 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

This repository shows how we can practice Acceptance Test-Driven Development to develop Shiny apps.

Start with a vague wish.

Every software project starts with some wish we want to fulfill.

At the beginning it's usually vague, like:

"I wish I could manage my personal finances better."

After we capture the wish, we can start breaking it down into smaller pieces and think about what it actually means.

Turn the wish into User Stories.

  • "As a user, I want to see my total income and expenses so that I can understand my current financial situation."
  • "As a user, I want to categorize my expenses so that I can identify areas where I can save money."
  • "As a user, I want to set a monthly budget and track how much I have spent against it."

Create examples for each User Story.

Once we are ready with our User Stories, we can start creating examples that will fullfil the User Stories.

⚠️ A few antipatterns to avoid:

  • Confusing UI and behaviour. Don't write specifications with keywords like "click", "type", "select", "field", etc. If we don't speficy the UI, we can change it without changing the tests. It also allows us to innovate as we don't prescribe how the UI should look.
  • Making scenarios too long. Keep them short and focused on one outcome whenever possible.
  • No reuse of steps. How can there be a system with no common steps to different things? Think about how you phrase your steps and reuse them in different scenarios.
  • Too many scenarios. Don't use only scenarios as automated tests, use them to cover behaviour of users, not every possible edge case.

Example of a scenario for User Story 1.

Given I have recorded my income of $2000
And I have recorded my expenses of $500
When I view my dashboard
Then I should see my total income as $2000
And my total expenses as $500
And my net balance as $1500

Example of a scenario for User Story 2.

Given I have recorded an expense of $50 for "Groceries"
And an expense of $100 for "Entertainment"
When I view my categorized expenses
Then I should see "Groceries" total as $50
And "Entertainment" total as $100

Turn scenarios into executable specifications and working software.

You can see the implementation of tests and the app, but I recommend you go through the commits to see how the app was developed step by step.

  • Create tests/acceptance/ directory for acceptance tests.
  • Create .test_acceptance() command to conveniently run acceptance tests.
  • Create acceptance test stage on CI.
  • Create a test file in tests/acceptance/.
  • Create the first test case, put the scenario name in test_that().
  • Call functions that will become our domain specific language. The don't exist yet, create an interface that will be easy to read and feed needed parameters.
  • Implement domain specific language in a separate file, force verify functions to fail, so that we know our software is not working yet.
  • Verify that acceptance tests fail ❌.
  • Create a wrapper for shinytest::AppDriver to allow adding our own methods of interacting with the System Under Test (SUT).
  • Add a method for interacting with the SUT. It's purpose is to hide the implementation details of how we interact with the SUT. If we ever want to interact with the SUT in another way, we would implement another driver with a method with the same name and interface.
  • Call the driver method from the DSL function.
  • Tests should fail due to a timeout, tests want to interact with an element that doesn't exist yet ❌.
  • Implement the numeric input needed by the first scenario step.
  • Run tests to validate that tests pass successfully through record_income() step.
  • Tests should be still failing due to forced fail in verify function ❌.
  • Implement record_expense() step.
  • Run tests to check that tests fail at this step ❌.
  • Implement the numeric input needed by record_expense() step.
  • Run tests to validate that tests pass successfully through record_expense() step.
  • Tests should be still failing due to forced fail in verify function ❌.
  • Don't change the DSL, clicking the buttons is a part of record_income and record_expense actions. This is why not mentioning UI in the DSL matters, as it allows us to refactor the UI without changing the tests.
  • Modify the record_income and record_expense actions to include the button clicks.
  • Implement the buttons in production code.
  • Tests should be still failing due to forced fail in verify function ❌.
  • We expect that there will be an element from which we extract the numeric value.
  • Tests should be still failing, now due a timeout. Total income element is not yet implemented ❌.
  • Implement the business logic of calculating the total based on given income and expenses.
  • verify_total_income() should pass, other assertions should fail ❌.
  • Add business logic for calculating total expenses.
  • Tests should pass verify_total_expenses() step. verify_net_balance() should fail ❌.
  • Add business logic to verify net balance.
  • All assertions should pass βœ….
  • We can safely refactor the code, we will know if code still works as long as tests are passing.
  • Add a second test scenario to test if we can record multiple expenses.
  • Reuse existing steps to create a new scenario.
  • Implement a teardown function to allow running multiple scenarios.
  • Add unit tests for an object that the app will interact with to store data to a persistent storage.
  • I want to use a registry object to run operations on a storage object. The storage will be a CSV file, but it could as well be a database connection or a S3 bucket.
  • This is an implementation detail of the app, it doesn't change the behavior of the app – tests are added to tests/testthat/, no changes in acceptance tests are needed.
  • Create a specification for an interface that will connect the app with a CSV file.
  • Create an implementation objects perviously specified in tests.
  • Leave the doors open for other implementations of interfaces so that we could extend the implementation to use a database or a S3 bucket.
  • The new code is not used in the app code yet. We will do that in the next commits.
  • Use new interface of saving inputs in the app.
  • Update the acceptance test so that each test case uses it's own storage, making them independent of each other.
  • With the Users' needs satisfied (acceptance tests are passing), we can work on improving the style of the app.
  • We can move and style elements on the page any way we want.
  • As long as we don't change data-test attributes or types of elements, acceptance tests will pass.
  • If we change the type of component, e.g. numeric input to a dropdown, we only need to change the implementation of interaction with this component.

About

πŸ§ͺ An example of Acceptance Test-Driven Development of a Shiny app using {shinytest2} and {selenider}

Topics

Resources

Stars

Watchers

Forks

Languages