Skip to content

2 15 12 TDD Presentation by George

judytuna edited this page Feb 16, 2012 · 1 revision

2/15/12 (Wednesday) meeting: George from Kabam spoke to us about TDD. Notes!

TDD presentation by George from Kabam, our host for the evening

Why is testing so important?

  1. Ruby is dynamically typed: you don't have any guarantees on the class or structure of any object.
  2. Ruby is interpreted: calls and references are resolved at runtime (if i'm trying to send a method to a class, I won't know if that'll work or not til runtime). Learned about TDD in Java but didn't use it, because if I got my program to compile in Java, that was 90% of testing already, so you could get away without TDD in Java.
  3. Ruby is mutable: (almost) any class or library can be modified, even at runtime. "This is kind of a scary environment, so it's importatnt to be able to have tests so that your code is able to run"

Benefits of writing automated tests

  • Tests codify and describe expected behavior. "How many of you have read someone else's code and had no idea what was happening?" "A: I look at my OWN code and don't know what's going on sometimes" lol.
  • Test suites can be run repeatedly to detect code regressions ("if I write a piece of code here which introduces a bug somewhere else, you can detect that problem
  • You can refactor code with tests with ease of mind. "I've been on projects where there's crazy code, but no one changes because you think if it aint broke, don't fix it. So you leave messy code in there"

Test suites in Rails

You have the whole model view controller thing, so start from the bottom with your model.

  • Unit Tests: models and library classes: anything that's an isolated chunk of functionality

  • Functional Tests: what you use for testing controllers, focus on dispatching and stuff like that.

  • View Tests: HTML rendered templates: is what you're rendering what was expected An interesting story: rails 235 was released; the next day, a new version was released immediately… because there was a horrible bug detected in the view layer. Had they had their view tests written, they'd have caught it.

  • Helper Tests: helper logic (some peole roll these into the unit tests, but this is another suite you could have) The above are all specific parts of your code.

  • Integration Tests: Entire stack and external services. There are several ways you could test your entire stack. Test everything together, make sure

  • UI Tests:

    • Selenium: browser-based. Great tool for testing the browser because it runs on the browser, so you look at what's being presented in the browser, you can click on things, examine tags in there; if you have ajax behavior, you can test to make sure it's working properly; there are also tests you could do for javascript specifically (jasmine is one example)
    • Webrat
    • Capybara
  • Business Driven Development (BDD) Tests
  • super test that describes the function of an entire system
  • people praise BDD because you can write tests that look like they're written in english
  • you can describe particular behavior and have it directly mapped to your code
  • by writing a specification for how a program is to behave, then have actual tests backing it up to make sure it's true. great way of specifying how a program is actually supposed to run.
  • behavior driven development? you are writing the same sorts of tests that your'e describing, but it's a different way of thinking of testing. TDD is what the code does/returns. BDD is same tests but you're thinking about it from the point of view of the consumer that's testing. top layer: thinking from user's point of view. a layer down: brwoser's point of view. what the consumer expects to happen.

Q: is cucumber necessary? A: no. you could write BDD using the standard test framekworks. it applies to all levels of tests--it's a way of thinking about how you write the tests.

TDD is a continuous cycle

Before I learned TDD, I would typically come up with a general idea of the structure and write the code. Then I'd figure oout if the code is doing what i wanted it to do. With TDD, it's the reverse. And it's an ongoing continuous cycle.

  1. Write a failing test: make the tests go "red"
  • I want to change the behavior of some piece of code, so I write the test that explains what I want from that type of behavior. So you write the test, and since the code that would make it pass is not there, the test will fail.
  1. Add enough code to get the test to pass: make the tests "green"

  2. Refactor as appropriate

  • restructure the code and dimplify things. "DRY" means "don't repeat yourself" -- if you find the same code in multipele places, you can extract that. or if you think, I can make the code a lot simpler, but is it still passing?

That's all that TDD is about!

If you have an existing program and there's a bug that you need to fix, the first thing i'll do is write a test that exploits that bug (because my tests didn't catch this edge case before). That's a way to make sure I fully understand what the defect is in the program. Then I'll fix it, then I'll know the bug is fixed.

Demo time!

We're gonna use rspec for this. I recommend rspec because it has a better syntax in general for describing tests, and there are a few advantages of rspec over testunit (testunit is the default). There are many many testing frameworks, but rspec and testunit are the most common.

We started with a user model already created and migrated. It has fields for first and last name.

Look in the project directory. There's a folder called spec where the tests live. In the spec, there's a structure similar to your app directory: controllers, helpers, models; this is where your test suites go. And this would be where your cucumber suite would go too.

1st thing you do when you're in rspec: tell it what your'e actually going to be working on. Here, i am describing a user:

describe User do

Then you describe your test conditions:

it "should have a first name" 

Then you create a user and check.

user = User.create(:name => 'George')

Then you test. The following line is called an assertion:

user.first_name.should == 'George' 

''''user.first_name.should'''' is what we're testing, and '''''George''''' is the expectation. In rspec you have some extra syntax that's added to Ruby here. ''''should'''' is a method that says, "hey I'm going to take ''''user.first_name'''' (the subject we're testing), and it should equal the thing on the other side of the ''''=='''' which is '''''George''''' in this example)

So what you end up with is the following, in app/spec/models/user_spec.rb:

describe User do
  it "should have a first name" do
    user = User.create(:name => 'George')
    user.first_name.should == 'George'

I run the test and it passes!

Let's think about some of the behaviors that I might want. Let's say every user must enter an email address. In app/specs/models/user_spec.rb:

it "should require an email address" do 
  user = User.create
  user.errors_on(:email).should == ["must be entered"]
end
  • ''''user = User.create'''': We're creating a new user and instantiating in the database.

Ran the test. It failed because we got no errors (we were expecting an error with the text, "must be entered").

Now, go to user and add a validator! In app/models/user.rb:

validates_presence_of :email

Ran test again. We got "can't be blank" as the error message, it's the default one. Okay, go back and change the test to say "can't be blank" and now the test passes =D

Next test: make sure that the user's email address is unique. In app/spec/models/user_spec.rb:

it "should require unique email address" do
  user_1 = User.create(:email => 'geroge@kabam.com')
  user_2 = User.create(:email => 'geroge@kabam.com')
  user_2.errors_on(:email).should == ['must be unique']
end

Create 1 user, the another with same email address. Test failed cuz we got no errors. Let's make this test pass now by adding this line to user.rb

validates_uniqueness_of :email 

Now test again, and still fail because we get the error message "has already been taken" which is the default. So we'll have it return a different error message instead, like this in user.rb:

validates_uniqueness_of :email, :message => 'must be unique'

Now it passes!

do another one…. let's say there's a method to get the full name, which is first and last name. We'll have one particular test condition that has a full name. but we'll have three different test situations that we might want to test as well. let's say someone has a first name but no last name; what if they have a last name but no first name; what if they have both; what if they have neither? let's create a context. In app/spec/models/user_spec.rb:

context "full name" do 
  it "should return first and last names" do 
    user = User.new(:first_name => 'George' :last_name => 'Feil')
    user.full_name.should == 'Geroge Feil'
  end

  it "should return first name if last name is blank" do
    user = User.new(:first_name => 'Geroge')
    user.full_name.should == 'George'
  end
end

2nd one: expected 'George' but got 'George ' with a space!

To make these pass, go into user.rb:

def full_name
  return first_name if last_name.blank?
  [firstname, lastname].join(' ')
end

now what if the first name is blank and we have a last name?

in user_spec.rb:

  it "should return last name if first name is blank" do 
    user = User.new(:last_name => 'Feil')
    user.full_name.should == 'Feil'
  end

In user.rb:

def full_name
  return first_name if last_name.blank?
  return last_name if first_name.blank?
  [firstname, lastname].join(' ')
end

Q: is there a particular order those lines of code have to be in? A: Well, in this case, it doesn't matter because it returns and stops going at the first one that's true. But it does go through each line sequentially.

Run all the test. 6 examples, all passed. 1 more to worry about. What if there's no name?

it "should return nothing if there's no first or last name" do
  user = User.new
  user.full_name.should be_nil
end

Hmm, green already. Is this particular test okay because it's testing an edge case and my code already catches that edge case? If you find yourself in a situation when you're writing a test that's supposed to exploit a bug or correct behavior that you think is not proper, and you get a false green, then the test is defective or I didn't understand what the problem is.

Now we can refactor!

def full_name
  [firstname, lastname].compact.join(' ')
end

didn't work because if you call join on anything (even nil), you get a string back, so we got an empty string.

def fill_name
  name = [first_name, last_name].compact
  name.join(' ') if name.any?
end

passes! if name is nil, then any will return false. Sarah: when i think about refactoring, it works now, how can I rewrite it so taht it still works George: when i was a java developer, i write a lot of comments, but not as much in ruby. write your ruby so that it's readable. if you find yourself wanting to add a comment in ruby, ask yourself, can i change this so that it's more clear what i'm doing?

That gives you the gist of how to write tests. Here are a few closing points:

Testing guidelines:

  • Test one aspect at a time - if you have a whole bunch of assertions in a single test, the first one that fails will cause the test to break, so you wouldn't know necessarily what's broken.
  • test in isolation
  • limit the scope of your test -- use mocks and fixtures as appropriate (mocking is substituting a fake object for something that you expect to be an actual database record. i can use a double or a stub) -- this doesn't apply to integration, Selenium, and Cucumber tests