This is a difficult problem. Many of the "methods" in a web application take a HTTP request as input and produce HTML output with the side effect of using the database and updating some kind of session state somewhere. This is a worst-case scenario for unit testing.
HTML is tough because you really want to isolate the data that comes specifically from a given method as opposed to the boiler-plate and layout scaffolding which is common to every page. That boiler plate could change to a new color scheme or something else independent of the logic of your application that could potentially break all your tests. The database is particularly difficult because a good deal of data often needs to be set up before you can meaningfully test anything but the login screen.
I think the best approach is to use many different kinds of testing and divide and conquer.
- For small applications, a manual regression test plan is a good place to start.
- When you make components that are truly independent, write JUnit tests for them.
- If you are using servlets, you should be able to provide a fake HTTP request or fake parameters and get the output from the servlet directly. Well formatted XHTML will parse fairly easily with regular expressions. You can use unit-testing techniques against pre-loaded data in the database.
- Set up integration tests with your unit test tools (someone is going to flame me for this). Make a test that builds up a set of test data in the database using the screens of the system (as in the point above) more or less like a user of the system would. All your tests will depend on the core test suite which sets up the data for the other tests. When your regression test gets too big, this is a great alternative to manual testing.
- You may be able to use an in-memory database with a tool like Hibernate instead of setting up a complete clone of your production database and masking the data.
- Use something like Selenium to test from a browser.
- Set your master servlet up to page you whenever an exception is thrown, and remove all catch statements from your code (except those that actually do something meaningful with the exception, like recover from it). This isn't testing, but it encourages very good testing.
- I often find that the most useful testing occurs when the tester explains how the system works to an absolute beginner. We find the most meaningful bugs when building little video help segments for new features. But I can do good testing when I explain and demonstrate the details of how the system works to an interested imaginary friend.
I think if you used a tiered approach to building your application with lots of independent layers, you'd have a much easier time testing. You may need a large team of programmers to get anything done with specialists for the database, the "back end" service layer on top of the database, the business logic layer, and the view layer. In that scenario, you can use traditional unit-testing techniques with the business logic and to some extent the other layers, but the view will always be a bear.
Edit: I've been thinking about this. For as long as I can remember MVC being applied to web applications, there has been a debate about what that means. For the record, I think Ruby/Rails got it most right - that Model really means Data Model. I like this interpretation because it is most compatible with making a service layer in your application. I recently read Steve Yegge's Platform Rant that was really eye-opening for me. Make the service your data model and eat your own dog food. That way someone else can make your creation into something you never imagined. The reason I bring it up here (besides the fact that I can't stop thinking about it) is that having divisions in your code makes it easier to test.
Look, I'm not in the layers-make-everything-better camp. I have to produce, and produce lots of features without a lot of help for a living. I'm just saying that if your project is mature enough that you aren't changing the database so much any more, consider turning your ORM nightmare into a service layer for Steve Yegge's reasons, but also because the additional interface should make your testing much easier and more effective.