• 沒有找到結果。

Iteration B2: Unit Testing of Models

在文檔中 Prepared exclusively for Jared Rosoff (頁 108-117)

Task B: Validation and Unit Testing

7.2 Iteration B2: Unit Testing of Models

end

After making this change, we rerun the tests, and they report that all is well.

But all that means is that we didn’t break anything. We need to do more than that. We need to make sure that our validation code that we just added not only works now, but will continue to work as we make further changes. We’ll cover functional tests in more detail in Section 8.4, Iteration C4: Functional Testing of Controllers, on page124. As for now, it is time for us to write some unit tests.

7.2 Iteration B2: Unit Testing of Models

One of the real joys of the Rails framework is that it has support for test-ing baked right in from the start of every project. As we have seen, from the moment you create a new application using the rails command, Rails starts generating a test infrastructure for you.

Let’s take a peek inside theunitsubdirectory to see what’s already there:

depot> ls test/unit helpers product_test.rb

product_test.rbis the file that Rails created to hold the unit tests for the model we created earlier with thegeneratescript. This is a good start, but Rails can help us only so much.

Let’s see what kind of test goodies Rails generated inside the filetest/unit/product_test.rb when we generated that model:

Download depot_b/test/unit/product_test.rb

require 'test_helper'

class ProductTest < ActiveSupport::TestCase

# Replace this with your real tests.

test "the truth" do assert true end

end

The generatedProductTestis a subclass ofActiveSupport::TestCase. The fact that ActiveSupport::TestCaseis a subclass of the Test::Unit::TestCaseclass tells us that Rails generates tests based on the Test::Unit framework that comes prein-stalled with Ruby. This is good news because it means if we’ve already been testing our Ruby programs with Test::Unit tests (and why wouldn’t we be?),

ITERATIONB2: UNITTESTING OFMODELS 109

then we can build on that knowledge to test Rails applications. If you’re new to Test::Unit, don’t worry. We’ll take it slow.

Inside this test case, Rails generated a single test called"the truth". Thetest...do syntax may seem surprising at first, but here ActiveSupport is combining a class method, optional parenthesis, and a block to make defining a test method just the tiniest bit simpler for you. Sometimes it is the little things that make all the difference.

Theassertline in this method is an actual test. It isn’t much of one, though—

all it does is test that true is true. Clearly, this is a placeholder, but it’s an important one, because it lets us see that all the testing infrastructure is in place.

A Real Unit Test

Let’s get onto the business of testing validation. First, if we create a product with no attributes set, we’ll expect it to be invalid and for there to be an error associated with each field. We can use the model’svalid?andinvalid?methods to see whether it validates, and we can use theany?method of the error list to see whether or not there is an error associated with a particular attribute.

Now that we know what to test, we need to know how to tell the test framework whether our code passes or fails. We do that using assertions. An assertion is simply a method call that tells the framework what we expect to be true. The simplest assertion is the methodassert, which expects its argument to be true.

If it is, nothing special happens. However, if the argument toassertis false, the assertion fails. The framework will output a message and will stop executing the test method containing the failure. In our case, we expect that an empty Productmodel will not pass validation, so we can express that expectation by asserting that it isn’t valid.

assert product.invalid?

Let’s write the full test:

Download depot_c/test/unit/product_test.rb

test "product attributes must not be empty" do product = Product.new

We can rerun just the unit tests by issuing the commandrake test:units. When we do so, we now see two tests executed (the originalthe truthtest and our new test):

Report erratum this copy is (B8.0 printing, September 9, 2010)

Prepared exclusively for Jared Rosoff

ITERATIONB2: UNITTESTING OFMODELS 110

2 tests, 6 assertions, 0 failures, 0 errors

Sure enough, the validation kicked in, and all our assertions passed.

Clearly at this point we can dig deeper and exercise individual validations.

Let’s look at just three of the many possible tests.

First, we’ll check that the validation of the price works the way we expect:

Download depot_c/test/unit/product_test.rb

test "product price must be positive" do

product = Product.new(:title => "My Book Title" , :description => "yyy" ,

:image_url => "zzz.jpg" ) product.price = -1

assert product.invalid?

assert_equal "must be greater than or equal to 0.01" , product.errors[:price].join('; ' )

product.price = 0 assert product.invalid?

assert_equal "must be greater than or equal to 0.01" , product.errors[:price].join('; ' )

product.price = 1 assert product.valid?

end

In this code we create a new product and then try setting its price to -1, 0, and +1, validating the product each time. If our model is working, the first two should be invalid, and we verify the error message associated with the price attribute is what we expect. The last price is acceptable, so we assert that the model is now valid. (Some folks would put these three tests into three separate test methods—that’s perfectly reasonable.)

Next, we’ll test that we’re validating that the image URL ends with one of .gif, .jpg, or .png:

Download depot_c/test/unit/product_test.rb

def new_product(image_url)

Product.new(:title => "My Book Title" , :description => "yyy" ,

:price => 1,

:image_url => image_url) end

test "image url" do

ITERATIONB2: UNITTESTING OFMODELS 111

ok = %w{ fred.gif fred.jpg fred.png FRED.JPG FRED.Jpg http://a.b.c/x/y/z/fred.gif }

bad = %w{ fred.doc fred.gif/more fred.gif.more }

ok.each do |name|

assert new_product(name).valid?, "#{name} shouldn't be invalid"

end

bad.each do |name|

assert new_product(name).invalid?, "#{name} shouldn't be valid"

end end

Here we’ve mixed things up a bit. Rather than write out the nine separate tests, we’ve used a couple of loops, one to check the cases we expect to pass validation, the second to try cases we expect to fail. At the same time, we factored out the common code between the two loops.

You’ll notice that we’ve also added an extra parameter to our assert method calls. All of the testing assertions accept an optional trailing parameter con-taining a string. This will be written along with the error message if the asser-tion fails and can be useful for diagnosing what went wrong.

Finally, our model contains a validation that checks that all the product titles in the database are unique. To test this one, we’re going to need to store product data in the database.

One way to do this would be to have a test create a product, save it, then create another product with the same title, and try to save it too. This would clearly work. But there’s a much simpler way—we can use Rails fixtures.

Test Fixtures

In the world of testing, a fixture is an environment in which you can run a test. If you’re testing a circuit board, for example, you might mount it in a test fixture that provides it with the power and inputs needed to drive the function to be tested.

In the world of Rails, a test fixture is simply a specification of the initial con-tents of a model (or models) under test. If, for example, we want to ensure that ourproductstable starts off with known data at the start of every unit test, we can specify those contents in a fixture, and Rails will take care of the rest.

You specify fixture data in files in thetest/fixturesdirectory. These files contain test data in either comma-separated value (CSV) or YAML format. For our YAML

֒→ page73

tests, we’ll use YAML, the preferred format. Each fixture file contains the data for a single model. The name of the fixture file is significant; the base name of the file must match the name of a database table. Because we need some data for aProductmodel, which is stored in theproductstable, we’ll add it to the file

Report erratum this copy is (B8.0 printing, September 9, 2010)

Prepared exclusively for Jared Rosoff

ITERATIONB2: UNITTESTING OFMODELS 112

calledproducts.yml. Rails already created this fixture file when we first created the model:

Download depot_b/test/fixtures/products.yml

# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html

one:

The fixture file contains an entry for each row that we want to insert into the database. Each row is given a name. In the case of the Rails-generated fixture, the rows are named one and two. This name has no significance as far as the database is concerned—it is not inserted into the row data. Instead, as we’ll see shortly, the name gives us a convenient way to reference test data inside our test code. They also are the names used in the generated integration tests, so for now, we’ll leave them alone.

Inside each entry you’ll see an indented list of name/value pairs. Just like in yourconfig/database.yml, you must use spaces, not tabs, at the start of each of the data lines, and all the lines for a row must have the same indentation. Be careful as you make changes because you will need to make sure the names of the columns are correct in each entry; a mismatch with the database column names may cause a hard-to-track-down exception.

Let’s add some more data to the fixture file with something we can use to test our product model. (Note that you do not have to include theidcolumn in test fixtures.)

Download depot_c/test/fixtures/products.yml

ruby:

title: Programming Ruby 1.9

description:

Ruby is the fastest growing and most exciting dynamic language out there. If you need to get working programs delivered fast, you should add Ruby to your toolbox.

price: 49.50

image_url: ruby.png

Now that we have a fixture file, we want Rails to load up the test data into the products table when we run the unit test. And, in fact, Rails is already doing

ITERATIONB2: UNITTESTING OFMODELS 113

David Says. . .

Picking Good Fixture Names

Just like the names of variables in general, you want to keep the names of fix-tures as self-explanatory as possible. This increases the readability of the tests when you’re asserting that product(:valid_order_for_fred) is indeed Fred’s valid order. It also makes it a lot easier to remember which fixture you’re supposed to test against without having to look upp1ororder4. The more fixtures you get, the more important it is to pick good fixture names. So, starting early keeps you happy later.

But what do we do with fixtures that can’t easily get a self-explanatory name likevalid_order_for_fred? Pick natural names that you have an easier time associ-ating to a role. For example, instead of usingorder1, usechristmas_order. Instead ofcustomer1, usefred. Once you get into the habit of natural names, you’ll soon be weaving a nice little story about how fredis paying for hischristmas_order with hisinvalid_credit_cardfirst, then paying with hisvalid_credit_card, and finally choosing to ship it all off toaunt_mary.

Association-based stories are key to remembering large worlds of fixtures with ease.

this (convention over configuration for the win!), but you can control which fixtures to load by specifying the following line intest/unit/product_test.rb:

class ProductTest < ActiveSupport::TestCase fixtures :products

#...

end

Thefixtures directive loads the fixture data corresponding to the given model name into the corresponding database table before each test method in the test case is run. The name of the fixture file determines the table that is loaded, so using:productswill cause theproducts.ymlfixture file to be used.

Let’s say that again another way. In the case of ourProductTestclass, adding thefixturesdirective means that theproductstable will be emptied out and then populated with the three rows defined in the fixture before each test method is run.

Theproductsmethod indexes into that table. We need to change the index used to match the name we gave in the fixture.

So far, we’ve been doing all our work in the development database. Now that we’re running tests, though, Rails needs to use a test database. If you look in

Report erratum this copy is (B8.0 printing, September 9, 2010)

Prepared exclusively for Jared Rosoff

ITERATIONB2: UNITTESTING OFMODELS 114

thedatabase.ymlfile in theconfigdirectory, you’ll notice Rails actually created a configuration for three separate databases.

db/development.sqlite3 will be our development database. All of our pro-gramming work will be done here.

db/test.sqlite3is a test database.

db/production.sqlite3is the production database. Our application will use this when we put it online.

Each test method gets a freshly initialized table in the test database, loaded from the fixtures we provide. This is automatically done by therake test com-mand, but can be done separately by runningrake db:test:prepare.

Using Fixture Data

Now that we know how to get fixture data into the database, we need to find ways of using it in our tests.

Clearly, one way would be to use the finder methods in the model to read the data. However, Rails makes it easier than that. For each fixture it loads into a test, Rails defines a method with the same name as the fixture. You can use this method to access preloaded model objects containing the fixture data:

simply pass it the name of the row as defined in the YAML fixture file, and it’ll return a model object containing that row’s data. In the case of our product data, calling products(:ruby) returns a Product model containing the data we defined in the fixture. Let’s use that to test the validation of unique product titles:

Download depot_c/test/unit/product_test.rb

test "product is not valid without a unique title" do

product = Product.new(:title => products(:ruby).title, :description => "yyy" ,

:price => 1,

:image_url => "fred.gif" )

assert !product.save

assert_equal "has already been taken" , product.errors[:title].join('; ' ) end

The test assumes that the database already includes a row for the Ruby book.

It gets the title of that existing row using this:

products(:ruby).title

It then creates a new Product model, setting its title to that existing title. It asserts that attempting to save this model fails and that thetitleattribute has the correct error associated with it.

ITERATIONB2: UNITTESTING OFMODELS 115

If you want to avoid using a hard-coded string for the Active Record error, you can compare the response against its built-in error message table:

Download depot_c/test/unit/product_test.rb

test "product is not valid without a unique title - i18n" do product = Product.new(:title => products(:ruby).title,

:description => "yyy" ,

We will cover the I18n functions in Chapter 15, Task J: Internationalization, on page233.

Now we can feel confident that our validation code not only works, but will continue to work. Our product now has a model, a set of views, a controller, and a set of unit tests. It will serve as a good foundation upon which to build the rest of the application.

What We Just Did

In just about a dozen lines of code, we augmented that generated code with validation:

• We ensured that required fields were present.

• We ensured that price fields were numeric, and at least one cent.

• We ensured that titles were unique.

• We ensured that images matched a given format.

• We updated the unit tests that Rails provided, both to conform to the constraints we have imposed on the model and to verify the new code that we added.

We show this to our customer, and while she agrees that this is something an administrator could use, she says that it certainly isn’t anything that she would feel comfortable turning loose on her customers. Clearly, in the next iteration we are going to have to focus a bit on the user interface.

Playtime

Here’s some stuff to try on your own:

• If you are using Git, now might be a good time to commit our work. You can first see what files we changed by using thegit statuscommand:

Report erratum this copy is (B8.0 printing, September 9, 2010)

Prepared exclusively for Jared Rosoff

ITERATIONB2: UNITTESTING OFMODELS 116

depot> git status

# On branch master

# Changed but not updated:

# (use "git add <file>..." to update what will be committed)

#

# modified: app/models/product.rb

# modified: test/fixtures/products.yml

# modified: test/functional/products_controller_test.rb

# modified: test/unit/product_test.rb

# no changes added to commit (use "git add" and/or "git commit -a")

Since we only modified some existing files, and didn’t add any new ones, we can combine thegit addandgit commitcommands and simply issue a singlegit commitcommand with the-aoption:

depot> git commit -a -m 'Validation!'

With this done, we can play with abandon, secure in the knowledge that we can return to this state at any time using a singlegit checkout . com-mand.

• The validation option:lengthchecks the length of a model attribute. Add validation to the product model to check that the title is at least ten characters long.

• Change the error message associated with one of your validations.

(You’ll find hints athttp://www.pragprog.com/wikis/wiki/RailsPlayTime.)

In this chapter, we’ll see

• writing our own views,

• using layouts to decorate pages,

• integrating CSS,

• using helpers, and

• writing functional tests

Chapter 8

在文檔中 Prepared exclusively for Jared Rosoff (頁 108-117)