From: Steve F. <sm...@us...> - 2002-08-10 00:45:34
|
Update of /cvsroot/mockobjects/no-stone-unturned/doc/xdocs In directory usw-pr-cvs1:/tmp/cvs-serv30808/doc/xdocs Modified Files: doc-book.xml Added Files: jdbc_testfirst.xml Log Message: Ported jdbc chapter --- NEW FILE: jdbc_testfirst.xml --- <chapter> <title>Developing JDBC applications test-first</title> <section> <title>Introduction</title> <para> Many developers find unit testing software with third-party components, such as databases, hard to do. Usually the difficulties are because setting up the component to ensure no dependencies between unit tests is too complicated or slow. This paper uses JDBC, the Java database interface, to show how it is possible to avoid using a real database at all. It also shows how an approach based on Mock Objects can lead to more rigourous unit testing and, I believe, better structured code than working with a real database. </para> <para> There are two key skills for writing Mock Objects when developing code test-first. First, decide what a test would need to verify to show that it had passed, and add mock implementations of objects that represent those concepts. Second, do not attempt to reproduce real behaviour in a mock object; if they are becoming complex, revisit both the mock implementation and the code being tested to see if there is some intermediate concept to be factored out. </para> <para> To help structure the examples, let's imagine that our customers have commissioned us to write a mailing list system. Anyone can add and remove themselves from the list, but only the list owner can see the whole list and send messages to it. If we start by assuming that there's just one list, we have the following tasks: </para> <itemizedlist> <listitem><para> anyone can add their name and email address to the list; </para></listitem> <listitem><para> anyone who is a member of the list can remove themselves, using their email address; </para></listitem> <listitem><para> the owner of the list can see all the members of the list; </para></listitem> <listitem><para> the owner of the list can send a message to all the members. </para></listitem> </itemizedlist> </section> <!-- Introduction --> <section> <title>Add a member to the list</title> <para> We assume that access is controlled by the surrounding system and start to develop a <classname>MailingList</classname> class to manage the list. For a succesful insertion, we would expect certain things to happen: </para> <itemizedlist> <listitem><para> create a statement object with the appropriate insertion statement </para></listitem> <listitem><para> set the parameters for the insertion statement based on the member's email address and name </para></listitem> <listitem><para> execute the statement exactly once to make the insertion </para></listitem> <listitem><para> close the statement exactly once </para></listitem> </itemizedlist> <para>Let's write that into our first test.</para> <programlisting> public class TestMaililingList extends TestCaseMo { public void testAddNewMember() { connection.setExpectedPrepareStatementString(MailingList.INSERTION_SQL); statement.addExpectedSetParameters(new Object[] {EMAIL, NAME}); statement.setExpectedExecuteCalls(1); statement.setExpectedCloseCalls(1); } <emphasis>[...]</emphasis> }</programlisting> <para> This tells us that we will need to check assertions about the connection and statement objects. It also pushes us towards passing the connection object through to the mailing list object when we make the call to add a member. If we rename our database objects to make it explicit that they are mock implementations, the test becomes: </para> <programlisting> public class TestMaililingList extends TestCaseMo { public void testAddNewMember() throws SQLException { mockConnection.setExpectedPrepareStatementString(MailingList.INSERTION_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL, NAME}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); mailingList.addMember(mockConnection, EMAIL, NAME); mockStatement.verify(); mockConnection.verify(); } <emphasis>[...]</emphasis> }</programlisting> <para> Note that <function>testAddNewMember()</function>declares <classname>SQLException</classname> in its throws clause. This means that any <classname>SQLException</classname> will be caught by the JUnit framework and reported as an error (a breakdown in our environment). Strictly speaking, we should catch it and convert it to a failure (a mistake in coding), but this style is lightweight and accurate enough for many projects. </para> <para>We set the test up in another method:</para> <programlisting> public class TestMaililingList extends TestCaseMo { private MailingList mailingList = new MailingList(); private MockConnection mockConnection = new MockConnection(); private MockPreparedStatement mockStatement = new MockPreparedStatement(); public void setUp() { mockConnection.setupPreparedStatement(mockStatement); } <emphasis>[...]</emphasis> }</programlisting> <para> The simplest implementation to pass this first test is just to work through each of the expectations that we've set: </para> <programlisting> public class MailingList { public void addMember(Connection connection, String emailAddress, String name) throws SQLException { PreparedStatement statement = connection.prepareStatement(INSERT_SQL); statement.setString(1, emailAddress); statement.setString(2, name); statement.execute(); statement.close(); } }</programlisting> <para>You may have noticed that this implementation does not ensure that a statement will always be closed; we'll get to that later. </para> <section> <title>What have we learned?</title> <para> This first test is very localised. It tests exactly one thing, the successful addition of a list member, and has no external dependencies, such as the need for an installed database with its tables in the right state. In some situations, where infrastructure decisions have not been taken, this will allow a project to move ahead while the management are negotiating with your vendors. </para> <para> There are two aspects to testing database code: what we say to the database, and what the database expects to hear. This approach separates them, which makes it easier to pinpoint errors as the system grows. Of course, we will also need integration tests to check the whole process, but those should be more like an acceptance test for a larger module. </para> <para> This testing style pushes us towards passing objects around. In this case, we need access to the connection object to verify it, so the easiest thing is to pass it through to the mailing list along with the other parameters. In practice, many systems hold connections in pools to improve performance, so passing through a connection with each action is probably the right approach. We have found that this style of test-first programming pushes us towards the right design so often that we have come to rely on it. </para> </section> </section> <!-- add a member to the list --> <section> <title>Add an existing member to the list</title> <para> What happens if someone tries to add an email address twice? After further discussion, our customers decide that we should not accept the change and show an error message; we decide to throw a <classname>MailingListException</classname> when this happens. We can write a test to verify that such an exception is thrown if there is a duplicate record. </para> <programlisting> public void testAddExistingMember() throws SQLException { mockStatement.setupThrowExceptionOnExecute( new SQLException("MockStatment", "Duplicate", DatabaseConstants.UNIQUE_CONSTRAINT_VIOLATED)); mockConnection.setExpectedPrepareStatementString(MailingList.INSERTION_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL, NAME}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); try { mailingList.addMember(mockConnection, EMAIL, NAME); fail("should have thrown an exception"); } catch (MailingListException expected) { } mockStatement.verify(); mockConnection.verify(); }</programlisting> <para> The method <function>setupThrowExceptionOnExecute()</function> stores an exception that the mock SQL statement will throw when <function>>execute()</function> is called. We can force such events because our coding style allows us to pass a mock implementation through to the code that uses the statement. We still check that the right parameters are passed through to the connection and statement, and that <function>execute</function> and <function>close</function> are each called once. Our test ensures that this exception is caught and translated into a <classname>MailingListException</classname>. If this doesn't happen, then the code will step through from <function>addMember</function> to the <function>fail</function> call and throw an assertion error. Even with the extra complexity, the test is still precise and self-explanatory. </para> <para>To pass this test, we change the implementation to:</para> <programlisting> public class MailingList { public void addMember(Connection connection, String emailAddress, String name) throws MailingListException, SQLException { PreparedStatement statement = connection.prepareStatement(INSERT_SQL); <emphasis>try {</emphasis> statement.setString(1, emailAddress); statement.setString(2, name); statement.execute(); <emphasis>} catch (SQLException ex) { if (ex.getErrorCode() == DatabaseConstants.UNIQUE_CONSTRAINT_VIOLATED) { throw new MailingListException("Email address exists"); } else { throw ex; } } finally { statement.close(); }</emphasis> } }</programlisting> <para> We've added a <token>finally</token> block to make sure that the statement will always be closed, even if there are failures when using it. Interestingly, the code now also passes another test: </para> <programlisting> public void testPrepareStatementFailsForAdd() throws MailingListException, SQLException { <emphasis>mockConnection.setupThrowExceptionOnPrepare(new SQLException("MockConnection"));</emphasis> mockConnection.setExpectedPrepareStatementString(MailingList.INSERT_SQL); mockStatement.setExpectedExecuteCalls(0); mockStatement.setExpectedCloseCalls(0); try { list.addMember(mockConnection, EMAIL, NAME); fail("Should have thrown exception"); } catch (SQLException expected) { } mockConnection.verify(); mockStatement.verify(); }</programlisting> <para> which checks that we do not try to execute or close the statement should the connection fail, although we still need to confirm that we made the right <function>prepareStatement</function> request. </para> <section> <title>What have we learned?</title> <para> One of the hardest things to test is the throwing and management of exceptions. I have seen a lot of code that does not clean up resources on failure, or does so but is too complicated. Incremental, test-first programming is very effective at focussing a developer's attention on correct error handling by simulating exceptions that can be expensive, or even impossible, to generate from the real libary. Of course, how careful we should be depends on the application; we may not need precise cleanup for a single-action utility, but we certainly do for a long-lived server. </para> <para> The other design point to note from this example is that I have distinguished between mailing list exceptions (the system works, but was used incorrectly) and SQL exceptions (there was a system failure). As the application grows, we might want to translate those SQL exceptions into application-specific exceptions to help us manage exception handling. So far, however, that isn't necessary. </para> </section> </section><!-- Add an existing member to the list --> <section> <title>Remove a member from the list</title> <para> The next task is to remove someone from the list, based on their email address. The test for a successful removal is: </para> <programlisting> public void testRemoveMember() throws MailingListException, SQLException { mockStatement.setupUpdateCount(1); mockConnection.setExpectedPrepareStatementString(MailingList.DELETE_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); list.removeMember(mockConnection, EMAIL); mockConnection.verify(); mockStatement.verify(); }</programlisting> <para> Once again, we check that the right parameters are passed in and the right methods called. A successful removal should return an update count greater than 0, so we use <function>setupUpdateCount()</function> to preload a return value in the statement object. </para> <para> We can also test removing someone who is not in the list by setting the update count to 0, which would imply that no rows match the email address in the deletion request. </para> <programlisting> public void testRemoveMissingMember() throws SQLException { mockStatement.setupUpdateCount(0); mockConnection.setExpectedPrepareStatementString(MailingList.DELETE_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); try { list.removeMember(mockConnection, EMAIL); fail("Should have thrown exception"); } catch (MailingListException expected) { } mockConnection.verify(); mockStatement.verify(); }</programlisting> <para> An implementation of <function>removeMember()</function> to support both these tests is: </para> <programlisting> public void removeMember(Connection connection, String emailAddress) throws MailingListException, SQLException { PreparedStatement statement = connection.prepareStatement(DELETE_SQL); try { statement.setString(1, emailAddress); if (statement.executeUpdate() == 0) { throw new MailingListException("Could not find email address: " + emailAddress); } } finally { statement.close(); } }</programlisting> <section> <title>Refactoring the tests</title> <para> At this point we should start to notice repetition in the unit tests. In particular, each action on the member list requires a particular setup for the mock sql objects, and we always verify the same two objects. We refactor these into some helper methods: </para> <programlisting> private void setExpectationsForAddMember() { setExpectationsForPreparedStatement(MailingList.INSERT_SQL, new Object[] {EMAIL, NAME}); } private void setExpectationsForRemoveMember() { setExpectationsForPreparedStatement(MailingList.DELETE_SQL, new Object[] {EMAIL}); } private void setExpectationsForPreparedStatement(String sqlStatement, Object[] parameters) { mockConnection.setExpectedPrepareStatementString(sqlStatement); mockStatement.addExpectedSetParameters(parameters); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); } private void verifyJDBC() { mockConnection.verify(); mockStatement.verify(); }</programlisting> <para> This allows us to simplify our existing tests and make them easier to read, for example: </para> <programlisting> public void testRemoveMember() throws MailingListException, SQLException { mockStatement.setupUpdateCount(1); setExpecationsForRemoveMember(); list.removeMember(mockConnection, EMAIL); verifyJDBC(); } public void testRemoveMissingMember() throws SQLException { mockStatement.setupUpdateCount(0); setExpectationsForRemoveMember(); try { list.removeMember(mockConnection, EMAIL); fail("Should have thrown exception"); } catch (MailingListException expected) { } verifyJDBC(); }</programlisting> </section> <section> <title>What have we learned?</title> <para> As the application grows, we find that the incremental costs of adding new tests falls as we start to benefit from the unit tests we have alread written. At first, the benefit is intellectual, we can write a new test by copying and adapting what we already have. The next stage is to refactor the tests to remove duplication. Maintaining and refactoring tests turns out to be as important as maintaining the code they exercise. The tests must be agile enough to follow the code as it is grows and changes. </para> <para> That said, we must maintain a balance here and not refactor the tests until they become too obscure to read; this applies particularly to verifications. In our example, the two verifiable objects are obvious enough that we can move them into a method with a meangingful name. Elsewhere it may be better to leave the calls to <function>verify()</function>in each test case to make it clear to the reader exactly what is being tested. </para> </section> </section> <!-- Remove a member from the list --> <section> <title>Show the members of the list</title> <para> Our next requirement is to show all the members of the list. The first question is how can we tell that we've seen everyone, so we create an object to work through all the list members; let's call it <classname>ListAction</classname> for now. We will set up a mock database and ask the <classname>ListAction</classname> object to verify that it has received the values that we expect it to when called by the <classname>MailingList</classname> object. </para> <section> <title>Extracting a single row</title> <para> The simplest first test is for a list with one member. We should ensure that the <classname>ListAction</classname> object receives the right member details, that the ResultSet is asked for the right column values and that <function>next()</function> is called twice. We also check the usual expectations about creating and closing a statement and executing the query. Our first test implements exactly this: </para> <programlisting> public void testListOneMember() throws SQLException { MockSingleRowResultSet mockResultSet = new MockSingleRowResultSet(); mockStatement.setupResultSet(mockResultSet); mockResultSet.addExpectedNamedValues( COLUMN_NAMES, new Object[] {EMAIL, NAME}); mockResultSet.setExpectedNextCalls(2); mockListAction.addExpectedMember(EMAIL, NAME); setExpectationsForListMembers(); list.applyToAllMembers(mockConnection, mockListAction); verifyJDBC(); mockResultSet.verify(); mockListAction.verify(); }</programlisting> <para> The value expectations in a <classname>MockSingleRowResultSet</classname> serve two purposes. They are checked to verify that the client has asked for exactly the right columns, either by name or position, and they store the values that are returned. It is arguable that these two functions should be split, but the pattern occurs often enough that we have implemented it in an <classname>ExpectationMap</classname>. The <classname>ListAction</classname> class maintains expectations about the name and email address of the members of the list. If we write enough code to fulfil just this test, we will have a sketch of the structure of the method: </para> <programlisting> public void applyToAllMembers(Connection connection, ListAction listAction) throws SQLException { Statement statement = connection.createStatement(); ResultSet results = statement.executeQuery(LIST_SQL); while (results.next()) { listAction.applyTo(results.getString("email_address"), results.getString("name")); } statement.close(); }</programlisting> </section> <section> <title>Extracting more than one member</title> <para> This first test proves that we can extract the right values from a valid row in a <classname>ResultSet</classname>. Once we have a test for this, our other tests can concentrate on other aspects, such as handling different numbers of valid rows. We do not need to re-test extraction in every case. For example, when testing for different numbers of list members we only need to set expectations for the number of times we call the <function>listAction</function> object, we do not need to check again that the right values have been received. This makes tests more precise and orthogonal, and so easier to understand when they fail. It also makes test cases and mock objects easier to write because each one does less. </para> <para> For example, the test for listing two members does not set any expectations for the list member name and email address. This version does set column names and some dummy values, but these are only necessary to avoid type casting errors when returning values from the <classname>ResultSet</classname>; they are also used to define the number of rows to return. The expectations we <emphasis>do</emphasis> set are concerned with the number of times various methods are called. </para> <programlisting> public void testListTwoMembers() throws SQLException { MockMultiRowResultSet mockResultSet = new MockMultiRowResultSet(); mockStatement.setupResultSet(mockResultSet); mockResultSet.setupColumnNames(COLUMN_NAMES); mockResultSet.setupRows(TWO_ROWS); mockResultSet.setExpectedNextCalls(3); mockListAction.setExpectedMemberCount(2); setExpectationsForListMembers(); list.applyToAllMembers(mockConnection, mockListAction); verifyJDBC(); mockResultSet.verify(); mockListAction.verify(); }</programlisting> <para> The test for a <classname>ResultSet</classname> with no rows is even simpler, we set up no row values at all and we tell the mock <classname>ListAction</classname> object to expect not to be called. At this point, we can also factor out setting up the mock <classname>ResultSet</classname> into a helper method. </para> <programlisting> public void testListNoMembers() throws SQLException { MockMultiRowResultSet mockResultSet = makeMultiRowResultSet(); mockResultSet.setExpectedNextCalls(1); mockListAction.setExpectNoMembers(); setExpectationsForListMembers(); list.applyToAllMembers(mockConnection, mockListAction); verifyJDBC(); mockResultSet.verify(); mockListAction.verify(); }</programlisting> </section> <section> <title>Handling failures</title> <para> Our simple implementation of <function>applyToAllMembers</function> can support all these tests, but we know that we still have to manage exceptions. We should write an additional test to confirm that we can cope if the <classname>ResultSet</classname> fails. As with the test for adding an existing member, we wrap the call to the <classname>ListAction</classname> object in a <token>try</token> block and fail if the exception is not thrown. We also use the same technique as before to tell the mock <classname>ResultSet</classname> to throw an exception when anyone tries to get a value. </para> <programlisting> public void testListResultSetFailure() { MockMultiRowResultSet mockResultSet = makeMultiRowResultSet(); mockResultSet.setupRows(TWO_ROWS); mockResultSet.setupThrowExceptionOnGet(new SQLException("Mock Exception")); mockResultSet.setExpectedNextCalls(1); mockListAction.setExpectNoMembers(); setExpectationsForListMembers(); try { list.applyToAllMembers(mockConnection, mockListMembers); fail("Should have thrown exception"); } catch (SQLException expected) { } mockResultSet.verify(); mockListMembers.verify(); }</programlisting> <para> One significant point about this test is that it clearly defines the behaviour of <function>applyToAllMembers</function> in the presence of failures. We do not, for example, try to carry on should a record fail. Developing test-first and publishing the tests gives users of the code a much clearer description of how to use it than most conventional documentation. </para> <para> Our implementation will not pass this test because it has no <token>finally</token> clause, so we add it now. </para> <programlisting> public void applyToAllMembers(Connection connection, ListAction listAction) throws SQLException { Statement statement = connection.createStatement(); try { ResultSet results = statement.executeQuery(LIST_SQL); while (results.next()) { listAction.applyTo(results.getString("email_address"), results.getString("name")); } } finally { statement.close(); } }</programlisting> <para> We might also add tests to check that we correctly handle failures when the connection cannot create a statement or the statement cannot execute the query. For a long-lived server, this sort of error handling avoids resource leakages that can be very difficult to find in production. </para> </section> <section> <title>Presenting the results</title> <para> Now we can ask a <classname>MailingList</classname> object to process all the members of the list by passing an object of type <classname>ListAction</classname>. We might present the results by implementing a <classname>ConvertToMemberNode</classname> class that adds each member's details to an XML document. That rendering is managed by another class, <classname>ListDocument</classname>, that is passed in when starting the action. </para> <programlisting> class ConvertToMemberNode implements ListAction { ListDocument listDocument; public ConvertToMemberNode(ListDocument aListDocument) { listDocument = aListDocument; } public void applyTo(String email, String name) { listDocument.startMember(); listDocument.addMemberEmail(email); listDocument.addMemberName(name); listDocumnet.endMember(); } }</programlisting> <para>Of course this new class should have its own set of unit tests. </para> </section> <section> <title>What have we learned?</title> <para> A common difficulty when testing networks of objects is the cost of setting up the test environment. These techniques show that we can manage this by being very precise about what each test verifies, and by providing just enough mock implementation to get us through. Teasing out the different aspects of what we are testing to make the tests more orthogonal has several advantages: the tests are more precise, so errors are easier to find; smaller tests are easier to implement and easier to read; and, the mock implementations can be more specialised and so simpler to implement. </para> <para> An alternative approach is to use one of the available stub JDBC implementations, or even a scratch copy of a real database. This is a good technique when getting started with unit testing or when retrofitting tests to an existing code base, but the mock object approach still has some advantages: </para> <itemizedlist> <listitem><para> the setup is cheaper, which makes the tests easier to maintain and quicker to run; </para></listitem> <listitem><para> failures can happen as soon as something unexpected happens, rather than verifying the result afterwards; </para></listitem> <listitem><para> we can verify behaviour such as the number of times times <function>close()</function> is called; and, </para></listitem> <listitem><para> we can force unusual conditions such as SQL exceptions. </para></listitem> </itemizedlist> </section> </section> <!-- Show the members of the list --> <section> <title>Send a message to all the members</title> <para> Our final requirement is to send a message to all the members of the list. We can do this by providing another implementation of the <classname>ListAction </classname> interface, in this case a <classname>AddMemberToMessage</classname> class. We have to make a design choice here about how to handle any messaging exceptions: we can either stop immediately, or collect the failures and process them at the end; for now, we shall stop processing immediately. The <classname>Message</classname> object is set up by the calling code, which then sends it to all the recipients. </para> <programlisting> class AddMembersToMessage implements ListAction { Message message; public AddMemberToMessage(Message aMessage) { message = aMessage; } public void applyTo(String email, String name) throws MailingListException { try { message.addRecipient(RecipientType.TO, new InternetAddress(email, name)); } catch (MessagingException ex) { throw new MailingListException("Adding member to message", email, name, ex); } } }</programlisting> <para> The <classname>MessagingException</classname> exception is checked, so we have to handle it here or propagate it through the code. We want to keep the <classname>ListAction </classname> interface generic, so we translate the <classname>MessagingException</classname> to a <classname>MailingListException</classname>, and propagate that instead. Now we have a new failure mode, so we write an additional unit test that ensures that we clean up properly after a <classname>MailingListException</classname>. </para> <programlisting> public void testListmembersFailure() throws SQLException { MockMultiRowResultSet mockResultSet = makeMultiRowResultSet(); mockResultSet.setupRows(TWO_ROWS); mockListAction.setupThrowExceptionOnMember(new MailingListException()); mockResultSet.setExpectedNextCalls(1); setExpectationsForListMembers(); try { list.applyToAllMembers(mockConnection, mockListAction); fail("Should have thrown exception"); } catch (MailingListException expected) { } mockResultSet.verify(); mockListAction.verify(); }</programlisting> <section> <title>Tell, don't ask</title> <para> This approach to test-first development has led to a solution that may appear needlessly complex, as against just returning a collection of members from the <classname>MailingList</classname>. Our experience, however, is that code written this way is much more flexible. For example, if we later decide to ignore message errors and keep sending, we can do so by collecting them in the <classname>AddMemberToMessage</classname> class and processing them after the message has been sent. Similarly, if our mailing list becomes very large, we can start to process recipients in small batches and do not have to worry about creating excessively large collections of list members. On the other hand, if we do want to return a list of members, we can still write another <classname>ListAction</classname> implementation that appends each member to a collection. </para> </section> </section> <!-- Send a message to all the members --> <section> <title>Summary</title> <para> This example shows how far you can you can isolate your unit tests from each other and avoid external dependencies by adopting Mock Objects. More interestingly, this approach to unit testing leads your code towards a style that is both flexible and easy to test, including testing system exceptions. As an application grows, common test infrastructure gets pushed into the mock implementations and the incremental cost of adding new tests drops substantially. In our experience, the first few tests with a complex new library, such as JDBC, can be slow to write but the mock implementations stabilise very quickly—particularly when using run-time proxies or an IDE to generate stub methods. </para> </section> <!-- Summary --> </chapter> Index: doc-book.xml =================================================================== RCS file: /cvsroot/mockobjects/no-stone-unturned/doc/xdocs/doc-book.xml,v retrieving revision 1.5 retrieving revision 1.6 diff -u -r1.5 -r1.6 --- doc-book.xml 9 Aug 2002 23:47:18 -0000 1.5 +++ doc-book.xml 10 Aug 2002 00:45:31 -0000 1.6 @@ -6,6 +6,8 @@ <!ENTITY part_introduction SYSTEM "file://@docpath@/introduction.xml"> <!ENTITY chp_how_mocks_happened SYSTEM "file://@docpath@/how_mocks_happened.xml"> + <!ENTITY chp_jdbc_testfirst SYSTEM "file://@docpath@/jdbc_testfirst.xml"> + <!ENTITY part_testing_guis SYSTEM "file://@docpath@/testing_guis_1.xml"> <!ENTITY chp_random SYSTEM "file://@docpath@/random.xml"> <!ENTITY notes SYSTEM "file://@docpath@/notes.xml"> @@ -42,9 +44,7 @@ <title>Servlets</title> </chapter> - <chapter> - <title>JDBC</title> - </chapter> + &chp_jdbc_testfirst; </part> &part_testing_guis; |