demo-shopping-cart-exercise-with-ruby

Shopping cart exercise with Ruby: how to program a shop, cart, till, checkout, etc. with TDD

https://github.com/joelparkerhenderson/demo-shopping-cart-exercise-with-ruby

Science Score: 44.0%

This score indicates how likely this project is to be science-related based on various indicators:

  • CITATION.cff file
    Found CITATION.cff file
  • codemeta.json file
    Found codemeta.json file
  • .zenodo.json file
    Found .zenodo.json file
  • DOI references
  • Academic publication links
  • Committers with academic emails
  • Institutional organization owner
  • JOSS paper metadata
  • Scientific vocabulary similarity
    Low similarity (10.1%) to scientific vocabulary
Last synced: 7 months ago · JSON representation ·

Repository

Shopping cart exercise with Ruby: how to program a shop, cart, till, checkout, etc. with TDD

Basic Info
  • Host: GitHub
  • Owner: joelparkerhenderson
  • Language: Ruby
  • Default Branch: main
  • Size: 12.7 KB
Statistics
  • Stars: 5
  • Watchers: 2
  • Forks: 1
  • Open Issues: 0
  • Releases: 0
Created almost 5 years ago · Last pushed about 1 year ago
Metadata Files
Readme Citation

README.md

Demo shoppping cart exercise with Ruby

This is a programming exercise that creates a simple shopping cart program.

The concept:

  • A shop sells apples, bananas, oranges.

  • A cart holds a user's items.

  • A till calculates the cost of a cart's items.

  • An offer is a potential discount such as buy one get one free.

  • A checkout program handles the input and output.

The domain driven design:

  • Shop provides item names and costs. This is constant i.e. the exercise data never changes.

  • Cart holds a user's items. This is a stateful i.e. the cart can be empty or contain a variety of items.

  • Till calculates and is purely functional i.e. it sums a total cost based on cart items, shop costs, and offers.

  • Offer calculates and is purely functional i.e. it evaluates each discount, if it applies, and for what amount.

  • Checkout is a command line interface i.e. it inputs, creates a cart, sends items to the till, and outputs.

Implementation preferences:

  • We favor separation of functional code from stateful code. For example we separate the Till concept (which is functional) and the Cart concept (which is stateful).

  • We favor separation of domain concerns. For example we separate the Till concept (which focuses on the concern of tallying a total cost) and the Offer concept (which focuses on the concern of special-case discounts).

  • We favor separation of files. For example we will create separate files shop.rb, cart.rb, till.rb, offer.rb, checkout.rb.

Testing preferences:

  • We favor readable test names that have plenty of detail, rather than test names that aren't as obvious.

  • We favor test driven development (TDD) which writes a test and runs the test to prove it fails, then implements the logic and runs the test to prove it succeeds.

  • We favor Ruby Minitest test style with assert(), rather than Minitest spec style with expect(), because the test style tends to be faster to write, clearer to document, and more effective to refactor as needed.

Exercise 1: Shopping cart

Build a shopping cart checkout system for a shop that sells apples and oranges.

  • Apples cost 60 cents each.

  • Oranges cost 25 cents each.

Build a checkout system which takes a cart of items scanned at the till and outputs the total cost.

  • For example: [ apple, apple, orange, apple ] => 2.05 dollars

Setup

Create a directory for the program then go into it:

sh mkdir demo && cd demo mkdir lib mkdir test

Create a typical file Rakefile that will run the tests:

```ruby require "rake/testtask"

Rake::TestTask.new(:test) do |t| t.libs << "test" t.libs << "lib" t.testfiles = FileList["test/**/test*.rb"] end

task :default => :test ```

Requirement: the apples cost 60 cents

Setup: We want a shop that has item names and costs:

sh touch lib/shop.rb touch test/test_shop.rb

TDD: Edit test_shop.rb and create a test for a new method item_cost:

```ruby class TestShop < Minitest::Test

def test_item_cost_with_apple
    item = "apple"
    assert_equal 60, Shop.item_cost(item)
end

end ```

Run rake and it fails as planned.

Implement: Edit shop.rb and create a class Shop with the data of items and costs:

```ruby class Shop

DATA = {
    items: {
        apple: { 
            cost: 60
        },
    }
}

def self.item_cost(item)
    DATA[:items][item.to_sym][:cost]
end

end ```

Run rake and it succeeds.

Notes:

  • The cost unit is USD cents which is 0.01 of a USD dollar.

  • Money units tend to be better to implement as the smallest-necessary unit rather than as a decimal floating point number. I.e. we implement using cents and integer math, not pound and floating point math.

  • The method Shop.item_cost is a class method, rather than an instance method. The class method is more-akin to a functional approach, and less-akin to an object oriented programming (OOP).

  • The DATA structure is general purpose. It's easy to read, easy to edit, and easy to extend. In a real program, the data would likely be managed by a database such as Postgres, and would likely have more capabilties such as for updates.

Requirement: the oranges cost 25 cents

TDD: Add oranges

```ruby class TestShop < Minitest::Test

def test_item_cost_with_apple
    item = "apple"
    assert_equal 60, Shop.item_cost(item)
end

def test_item_cost_with_orange
    item = "orange"
    assert_equal 25, Shop.item_cost(item)
end

end ```

Run rake and it fails as planned.

Immplementation: Add oranges:

``` class Shop

DATA = {
    items: {
        …
        orange: { 
            cost: 25
        },
    }
}

… ```

Run rake and it succeeds.

Requirement: a cart

Setup: We want a cart that can hold items:

sh touch lib/cart.rb touch test/test_cart.rb

TDD: Edit test_cart.rb and create a test for initialize that creates a cart that's empty:

```ruby class TestCart < Minitest::Test

def setup
    @cart = Cart.new
end

def test_initialize
    assert @cart.items.empty?
end

end ```

Run rake and it fails as planned.

Implement: Edit cart.rb and create a class that has an array of items that starts empty:

```ruby class Cart

attr_accessor :items

def initialize
    @items = []
end

end ```

Run rake and it succeeds.

Notes:

  • In a real program, we would likely have the Cart encapsulate the items array, such as with a getter and setter, rather than making the items array public as above-- which is simply to expedite this exercise.

  • In a real program, we would like make the Cart validate added items, and also provide related methods for removing items, saving items for later purchases, and the like.

Requirement: add items to the cart

TDD: Create a method add_items that takes items:

ruby class TestCart < Minitest::Test … def test_add_items assert_equal [], @cart.items @cart.add_items("apple", "orange") assert_equal ["apple", "orange"], @cart.items end …

Run rake and it fails as planned.

Implement: Add items and make it easy by using the Ruby splat operator to handle multiple items:

ruby class TestCart < Minitest::Test … def add_items(*items) @items.append(*items) end …

Run rake and it succeeds.

Requirement: calculate the total cost

Setup: We want a till that can calculate the total cost of items:

sh touch lib/till.rb touch test/test_till.rb

TDD: Edit test_till.rb and create a test for a new method total_cost:

ruby class TestTill < Minitest::Test … def test_total_cost_with_example_list items = ["apple", "apple", "orange", "apple"] assert_equal 205, Till.total_cost(items) end …

Run rake and it fails as planned.

Implement: Edit till.rb and create the method:

```ruby class Till

def self.total_cost(items)
    items.map{|item| Shop.item_cost(item)}.sum
end

end ```

Run rake and it succeeds.

Notes:

  • Much like the method Shop.item_cost, the method Till.total_cost is a class method, rather than as an instance method. The class method is more-akin to a functional approach, and less-akin to an object oriented programming (OOP).

Requirement: Build a checkout system which takes a cart of items scanned at the till and outputs the total cost

Setup: We want a checkout capability that reads input, calculates using the shop costs and cart items, and writes output:

sh touch lib/checkout.rb touch test/test_checkout.rb

TDD: Edit test_checkout.rb that runs a command that outputs "Total cost TODO":

```ruby require 'minitest/autorun' require './lib/checkout'

class TestCheckout < Minitest::Test def testcommand assertoutput("Total cost TODO\n") { puts ./lib/checkout.rb } end
end ```

Run rake and it fails as planned.

Implement: Create a file ./lib/checkout.rb that runs the command:

```ruby

!/usr/bin/env ruby

if FILE == $0 puts "Total cost TODO" end ```

Set permissions to executable:

sh chmod +x checkout.rb

Run rake and it succeeds.

TDD: Refine the test to make it output the total cost

```ruby class TestCheckout < Minitest::Test

def test_command
    assert_output("Total cost is 85 cents\n") {
        puts `./lib/checkout.rb apple orange`
    }
end   

end ```

Run rake and it fails as planned.

Implement: Refine the checkout to output the total cost:

```sh require './lib/cart' require './lib/till' require './lib/shop'

if FILE == $PROGRAMNAME cart = Cart.new cart.additems(ARGV) cost = Till.total_cost(cart.items) print "Total cost is #{cost} cents\n" end ```

Run rake and it succeeds.

TDD: Refine the test to output the total cost also as dollars:

```ruby class TestCheckout < Minitest::Test

def test_main
    assert_output("Total cost is 85 cents aka 0.85 dollars\n") {
        puts `./lib/checkout.rb apple orange`
    }
end   

end ```

Run rake and it fails as planned.

Implement: Refine the logic to output:

ruby if __FILE__ == $PROGRAM_NAME cart = Cart.new cart.add_items(ARGV) cost = Till.total_cost(cart.items) print "Total cost is #{cost} cents aka #{cost.to_f/100} dollars.\n" end

Run rake and it succeeds.

Notes:

  • The conversion of cost.to_f is because we need decimal division, rather than integer division.

  • In a real program, we would likely create a method main that sets up the environment such as requiring libraries and initializing a logger, and a method run that does the purpose of the program such as reading input, processing data, results, and printing results.

Step 2: Simple offers

The shop decides to introduce two new offers:

  • Buy one, get one free on apples.

  • 3 for the price of 2 on oranges.

Update your checkout functions accordingly.

Requirement: Add offers

Setup: We want an offer that can decide if a discount applies, and if so, for how much:

sh touch lib/offer.rb touch test/test_offer.rb

We recognize that "Buy one get one free" is equivalent to "2 for the price of 1". Thus both offers are "X for the price of Y", so we'll code it that way.

TDD: Edit test_offer.rb and add tests for a new method x_for_price_of_y.

  • We want an assertion for each kind of offer when it's included in the total cost i.e. when the offer is applicable thus the method returns a discount.

  • We want an assertion for each kind of offer when it's excluded in the total cost i.e. when the offer is inapplicable thus the method returns no discount).

  • Thus we're writing one conceptual test i.e. TDD style, with four test methods, each with one assertion. This is still true TDD, because the purpose is one concept.

  • Some people prefer to apprpoach this kind of TDD step-by-step with smaller code, such as writing one test that doesn't implement any offer and simply returns a constant 0; this step-by-step can be fine for bootstraping or exploring a new area, however that simple code is better retired in favor of tests with coverage of real cases. In the interest of space, the tests below show the outcome rather than the bootstrapping.

```ruby require 'minitest/autorun' require './lib/offer'

class TestOffer < Minitest::Test

def test_x_for_price_of_y_with_2_for_1_apples_include
    items = ["apple", "apple"]
    assert_equal -60, Offer.x_for_price_of_y(items, 2, 1, "apple")
end

def test_x_for_price_of_y_with_2_for_1_apples_exclude
    items = ["apple"]
    assert_equal 0, Offer.x_for_price_of_y(items, 2, 1, "apple")
end

def test_x_for_price_of_y_with_3_for_2_oranges_include
    items = ["orange", "orange", "orange"]
    assert_equal -25, Offer.x_for_price_of_y(items, 3, 2, "orange")
end

def test_x_for_price_of_y_with_3_for_2_oranges_exclude
    items = ["orange", "orange"]
    assert_equal 0, Offer.x_for_price_of_y(items, 3, 2, "orange")
end

end ```

Run rake and it fails as planned.

Implement: Edit offer.rb and create the method x_for_price_of_y:

```ruby class Offer

def self.x_for_price_of_y(items, item)
    (items.count(item) / x) * (x - y) * -Shop.item_cost(item)
end

end ```

Run rake and it should succeed for Offer.x_for_price_of_y but fail for Till.total_cost because we haven't updated it.

TDD: Edit test_till.rb and update test_total_cost_* with new offer tests that test the combination of both offers:

```ruby def testtotalcostwith2for1applesincludeand3for2orangesinclude items = ["apple", "apple", "orange", "orange", "orange"] assertequal 110, Till.totalcost(items) end

def testtotalcostwith2for1applesexcludeand3for2orangesexclude items = ["apple", "orange", "orange"] assertequal 110, Till.totalcost(items) end ```

Run rake and it fails as planned.

Implement: Edit till.rb and add the offers:

ruby def self.total_cost(items) items.map{|item| SHOP[item]}.sum + Offer.x_for_price_of_y(items, 2, 1, "apple") + Offer.x_for_price_of_y(items, 3, 2, "orange") end

Run rake and it should succeed for the new Till tests, but fail for the existing Till test test_total_cost_* because we haven't updated it.

TDD: Edit test_till.rb and replace the test test_total_cost_* with a method test_subtotal_cost_*:

ruby def test_subtotal_cost_with_example_list items = ["apple", "apple", "orange", "apple"] assert_equal(205, Till.subtotal_cost(items)) end

Run rake and it fails as planned.

Implement: Edit till.rb and update the method total_cost and create the method subtotal_cost:

ruby def self.subtotal_cost(items) items.map{|item| SHOP[item]}.sum end

Run rake and it succeeds.

Refactor: Edit till.rb to use the new method subtotal_cost:

ruby def self.total_cost(items) self.subtotal_cost(items) + Offer.x_for_price_of_y(items, 2, 1, "apple") + Offer.x_for_price_of_y(items, 3, 2, "orange") end

Run rake and it should succed.

Notes:

  • We favor a functional-style multiline calculation, rather than a mutation-style one-line-at-a-time calculation.

  • In a real application, we would likely write more tests, such as for edge cases (e.g. when there are no items) and for larger cases (e.g. when there are many apple and many oranges).

Step 3: More complicated offers

The shop adds bananas.

  • Bananas cost 20 cents each.

  • Bananas are added to the same buy one get one free offer as apples.

  • The cheapest item should be given free first.

Update your checkout functions accordingly.

Requirement: Add bananas that cost 20 cents

TDD: Edit test_shop.rb and add:

ruby def test_item_cost_with_banana item = "banana" assert_equal 0.20, Demo.item_cost(item) end

Run rake and it fails as planned.

Implement: Edit shop.rb and add lines for the banana:

ruby items: { apple: { cost: 60 }, banana: { cost: 20 }, orange: { cost: 25 }, }

Run rake and it succeeds.

Requirement: Add bananas offer of buy one get one free

TDD: Edit test_offer.rb and add tests for x_for_price_of_y_with_2_for_1_bananas that are akin to the tests for apples and oranges:

```ruby def testxforpriceofywith2for1bananasinclude items = ["banana", "banana"] assertequal -20, Offer.xforpriceofy(items, 2, 1, "banana") end

def testxforpriceofywith2for1bananasexclude items = ["banana"] assertequal 0, Offer.xforpriceofy(items, 2, 1, "banana") end ```

Run rake and it should succeed for the new tests because the implementation method already exists, but fail for the outdated tests Till.total_cost_*.

TDD: Edit test_till.rb and update the methods test_total_cost_* to:

```ruby def testtotalcostwith2for1applesexcludeand2for1bananasexcludeand3for2orangesexclude items = ["apple", "banana", "orange", "orange"] assertequal 130, Till.totalcost(items) end

def testtotalcostwith2for1applesincludeand2for1bananasincludeand3for2orangesinclude items = ["apple", "apple", "banana", "banana", "orange", "orange", "orange"] assertequal 130, Till.totalcost(items) end ```

Implement: Edit till.rb and add one line for the new offer:

ruby def self.total_cost(items) self.subtotal_cost(paid_items) + Offer.x_for_price_of_y(items, 2, 1, "apple") + Offer.x_for_price_of_y(items, 2, 1, "banana") + Offer.x_for_price_of_y(items, 3, 2, "orange") end

Run rake and it succeeds.

Requirement: The cheapest item should be given free first

TDD: Edit test_till.rb and add a test:

ruby def test_total_cost_with_cheapest_item_free items = ["apple", "banana", "orange"] assert_equal 85, Till.total_cost(items) end

Run rake and it fails as planned.

Implement: Edit till.tb and add a line that decides which item is free and which items are paid:

```ruby def self.totalcost(items) _freeitem, *paiditems = self.sortbycost(items) self.subtotalcost(paiditems) + Offer.xforpriceofy(paiditems, 2, 1, "apple") + Offer.xforpriceofy(paiditems, 2, 1, "banana") + Offer.xforpriceofy(paiditems, 3, 2, "orange") end

def self.sortbycost(items) items.sortby{|itemj| Till.itemcost(item)} end ```

Run rake and it succeeds.

Owner

  • Name: Joel Parker Henderson
  • Login: joelparkerhenderson
  • Kind: user
  • Location: California

Software developer. Technology consultant. Creator of GitAlias.com, NumCommand.com, SixArm.com, and many open source projects.

Citation (CITATION.cff)

cff-version: 1.2.0
title: Demo shoppping cart exercise with Ruby
message: >-
  If you use this work and you want to cite it,
  then you can use the metadata from this file.
type: software
authors:
  - given-names: Joel Parker
    family-names: Henderson
    email: joel@joelparkerhenderson.com
    affiliation: joelparkerhenderson.com
    orcid: 'https://orcid.org/0009-0000-4681-282X'
identifiers:
  - type: url
    value: 'https://github.com/joelparkerhenderson/demo-shopping-cart-exercise-with-ruby/'
    description: Demo shoppping cart exercise with Ruby
repository-code: 'https://github.com/joelparkerhenderson/demo-shopping-cart-exercise-with-ruby/'
abstract: >-
  Demo shoppping cart exercise with Ruby
license: See license file

GitHub Events

Total
  • Push event: 1
Last Year
  • Push event: 1

Committers

Last synced: over 1 year ago

All Time
  • Total Commits: 11
  • Total Committers: 1
  • Avg Commits per committer: 11.0
  • Development Distribution Score (DDS): 0.0
Past Year
  • Commits: 0
  • Committers: 0
  • Avg Commits per committer: 0.0
  • Development Distribution Score (DDS): 0.0
Top Committers
Name Email Commits
Joel Parker Henderson j****l@j****m 11
Committer Domains (Top 20 + Academic)

Issues and Pull Requests

Last synced: 12 months ago

All Time
  • Total issues: 0
  • Total pull requests: 0
  • Average time to close issues: N/A
  • Average time to close pull requests: N/A
  • Total issue authors: 0
  • Total pull request authors: 0
  • Average comments per issue: 0
  • Average comments per pull request: 0
  • Merged pull requests: 0
  • Bot issues: 0
  • Bot pull requests: 0
Past Year
  • Issues: 0
  • Pull requests: 0
  • Average time to close issues: N/A
  • Average time to close pull requests: N/A
  • Issue authors: 0
  • Pull request authors: 0
  • Average comments per issue: 0
  • Average comments per pull request: 0
  • Merged pull requests: 0
  • Bot issues: 0
  • Bot pull requests: 0
Top Authors
Issue Authors
Pull Request Authors
Top Labels
Issue Labels
Pull Request Labels