Index: django/test/testcases.py
===================================================================
--- django/test/testcases.py	(revision 6352)
+++ django/test/testcases.py	(working copy)
@@ -186,3 +186,141 @@
         self.failIf(template_name in template_names,
             (u"Template '%s' was used unexpectedly in rendering the"
              u" response") % template_name)
+
+    def assertNoBrokenLinks(self, response, internal_only=True):
+        """
+        Asserts that all the links within the response, when followed, return
+        a valid page (a 200) or a redirect (302).
+        
+        Blank links are also identified (such as <a href="">) as this is helpful
+        to identify when the url tag in <a href="{% url my-url-name arg1 %}"> 
+        fails.
+        
+        Internal page links (such as <a href="#content">Skip to content</a> are
+        also checked to ensure they are not broken (ie. that an element with the
+        id exists on the page).
+        
+        Current issues/thoughts:
+          * Should we follow 302's to verify the page redirects to a 200 result?
+        """
+        non_broken_status_codes = (200, 301, 302, 304)
+
+        # Create the parser to grab the internal and external links
+        import HTMLParser
+
+        class AnchorParser(HTMLParser.HTMLParser):
+            external_href_re = re.compile(r'^https?://', re.IGNORECASE)
+            ignore_href_re = re.compile(r'^(mailto|ftp):', re.IGNORECASE)
+            internal_id_href_re = re.compile(r'^#(.*)')
+            
+            def __init__(self):
+                self.hrefs_internal = []
+                self.hrefs_external = []
+                self.interal_page_link_ids = []
+                self.element_ids = []
+                self.reset()
+        
+            def handle_starttag(self, tag, attrs):
+                if tag == "a":                        
+                    for k, v in attrs:
+                        if k == "href":
+                            # For each href that we're not ignoring, save the
+                            # value and position
+                            if self.ignore_href_re.match(v):
+                                pass
+                            elif self.external_href_re.match(v):
+                                self.hrefs_external.append((v, self.getpos()))
+                            elif self.internal_id_href_re.match(v):
+                                # If this is of the form href="#content" then
+                                # remember the actual id "content".
+                                self.interal_page_link_ids.append(
+                                    (
+                                        self.internal_id_href_re.match(v).groups()[0],
+                                        self.getpos()
+                                    )                             
+                                )
+                            else:
+                                self.hrefs_internal.append((v, self.getpos()))
+                        elif k == "id":
+                            # An anchor link can have an id and be linked to
+                            # via an internal page link too.
+                            self.element_ids.append(v)
+                else:
+                # Go through the attributes of all the other tags so we know all 
+                # the element id's within the page for internal page links.
+                    for k, v in attrs:
+                        if k == "id":
+                            self.element_ids.append(v)
+
+        p = AnchorParser()
+        p.feed(response.content)
+        p.close()
+        
+        # Check the internal links first:
+        for link, (lineno, offset) in p.hrefs_internal:
+            self.failIf(
+                ''==link,
+                (u"The page contains a link with an empty href on line %(lineno)d.") % {
+                    'page': 'pagename',
+                    'lineno': lineno,
+                    'response': response                                                                   
+                }
+            )
+                
+            link_response = response.client.get(link)
+            self.failUnless(
+                link_response.status_code in non_broken_status_codes,
+                (u"The link '%(link)s' on line %(lineno)d appears to be broken (status is %(status)s)") % {
+                    'link': link,
+                    'lineno': lineno,
+                    'status': link_response.status_code                                                        
+                }           
+            )
+
+        # Next, check the internal page links:
+        for id, (lineno, offset) in p.interal_page_link_ids:
+            # If the id wasn't blank (ie. <a href="#"> then make sure that there
+            # was an element with the same id on the page somewhere.
+            if id:
+                self.failUnless(
+                    id in p.element_ids,
+                    (
+                        u"The internal link to #%(id)s on line %(lineno)d does"
+                        u" not link to a corresponding element with an "
+                        u"id=\"%(id)s\"." % {
+                            'id': id,
+                            'lineno': lineno                   
+                        }
+                    )
+                )
+            
+        # Then check the external links
+        if not internal_only:
+            import urllib2
+            from django.conf import settings
+
+            headers = {
+                "Accept": "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5",
+                "Accept-Language": "en-us,en;q=0.5",
+                "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.7",
+                "Connection": "close",
+                "User-Agent": settings.URL_VALIDATOR_USER_AGENT,
+            }
+
+            for link, (lineno, offset) in p.hrefs_external:
+                try:
+                    req = urllib2.Request(link, None, headers)
+                    u = urllib2.urlopen(req)
+                except ValueError:
+                    self.fail(
+                        u"The link '%(link)s' on line %(lineno)d appears to be invalid." % {
+                            'link': link,
+                            'lineno': lineno
+                        }
+                    )
+                except: # urllib2.URLError, httplib.InvalidURL, etc.
+                    self.fail(u"The link '%(link)s' on line %(lineno)d appears to be broken." % {
+                            'link': link,
+                            'lineno': lineno
+                        }
+                    )
Index: tests/regressiontests/test_client_regress/views.py
===================================================================
--- tests/regressiontests/test_client_regress/views.py	(revision 6352)
+++ tests/regressiontests/test_client_regress/views.py	(working copy)
@@ -1,6 +1,6 @@
 from django.contrib.auth.decorators import login_required
 from django.core.mail import EmailMessage, SMTPConnection
-from django.http import HttpResponse, HttpResponseRedirect, HttpResponseServerError
+from django.http import HttpResponse, HttpResponseRedirect, HttpResponseServerError, HttpResponseNotFound
 from django.shortcuts import render_to_response
 
 def no_template_view(request):
@@ -27,4 +27,111 @@
 def login_protected_redirect_view(request):
     "A view that redirects all requests to the GET view"
     return HttpResponseRedirect('/test_client_regress/get_view/')
-login_protected_redirect_view = login_required(login_protected_redirect_view)
\ No newline at end of file
+login_protected_redirect_view = login_required(login_protected_redirect_view)
+
+def no_broken_links_view(request):
+    return HttpResponse(
+    """
+    Lots of html stuff, including a few local links:
+      <a href="/test_client_regress/get_view/">here</a>
+      <a href="/test_client_regress/file_upload">This should redirect to add the /</a>
+      <a some="attribute" href="/test_client_regress/no_template_view/">
+        a redirect view. Doesn't matter if closing tag missing.
+        
+    Some external links:
+      <a href="http://djangoproject.com/weblog/">Django blog</a>
+
+    Some anchor links should be ignored, such as:
+      <a href="mailto:me@example.com">Send me an email</a>, or
+      <a href="ftp://example.com">Download from here</a>
+    
+    Including a <a lats of bad="stuff" href="http://djangoproject.com">Missing slash</a>
+    Whole bunch of other stuff before the page ends.
+    """                                     
+    )
+    
+def broken_external_link_view(request):
+    return HttpResponse(
+    """
+    Lots of html stuff, including a few local links:
+      <a href="/test_client_regress/get_view/">here</a>
+      <a href="/test_client_regress/file_upload">This should redirect to add the /</a>
+      <a some="attribute" href="/test_client_regress/no_template_view/">
+        a redirect view. Doesn't matter if closing tag missing.
+        
+    Some external links:
+      <a class="this one's fine" href="http://djangoproject.com/weblog">Django blog</a>
+    But this one's a 
+      <a href="http://djangoproject.com/badlink.html">Broken link</a>
+    Whole bunch of other stuff before the page ends.
+    """                                     
+    )
+    
+def broken_internal_link_view(request):
+    return HttpResponse(
+    """
+    Lots of html stuff, including a few local links:
+
+      <a class="test" href="/test_client_regress/broken_view/">A broken view</a>
+        
+    """                                     
+    )
+
+def bad_internal_link_view(request):
+    return HttpResponse(
+    """
+    Lots of html stuff, including a few local links:
+
+      <a class="test" href="/test_client_regress/bad_view/">A bad view</a>
+    
+    """                                     
+    )
+    
+def invalid_external_link_view(request):
+    return HttpResponse(
+    """
+    Lots of html stuff, including a few local links:
+
+      <a class="test" href="http://djangoproject&.com">An invalid link</a>
+        
+    """                                     
+    )
+
+def blank_link_view(request):
+    return HttpResponse(
+    """
+    If a link uses the url template tag to create the link for the href like 
+    this:
+     href="{% url my-url-name arg %}"
+    and fails, it will end up with blank href="", this would be useful to
+    catch!
+
+      <a class="test" href="">A blank link.</a>
+        
+    """                                     
+    )
+    
+def internal_page_link_view(request):
+    return HttpResponse(
+    """
+    A link which is just internal to the page, href="#content" needs to 
+    have a matching element with an id="content" on the page.
+
+      <a class="test" href="#content">This one should be fine</a>
+      
+      <a href="#">This one should be ignored (lots of JS uses)</a>
+      
+      <a href="#footer">But this one isn't valid</a> as there's no corresponding
+      element with the id="footer"
+      
+      <div id="content">
+        Here's the content
+      </div>
+    """                                     
+    )
+    
+def broken_view(request):
+    return HttpResponseServerError()
+
+def bad_view(request):
+    return HttpResponseNotFound()
Index: tests/regressiontests/test_client_regress/models.py
===================================================================
--- tests/regressiontests/test_client_regress/models.py	(revision 6352)
+++ tests/regressiontests/test_client_regress/models.py	(working copy)
@@ -233,6 +233,132 @@
         except AssertionError, e:
             self.assertEqual(str(e), "The form 'form' in context 0 does not contain the non-field error 'Some error.' (actual errors: )")        
 
+class AssertNoBrokenLinksTests(TestCase):
+    def test_no_broken_links(self):
+        "Tests that assertion confirms internal and external non-broken links."
+        
+        response = self.client.get('/test_client_regress/no_broken_links_view/')
+        self.assertEqual(response.status_code, 200)
+                
+        self.assertNoBrokenLinks(response, internal_only=False)
+        
+    def test_broken_external_link(self):
+        "Tests that assertion finds broken external links"
+
+        response = self.client.get('/test_client_regress/broken_external_link_view/')
+        self.assertEqual(response.status_code, 200)
+        
+        # No internal broken links:
+        self.assertNoBrokenLinks(response)
+
+        # But there is an external broken link:
+        assertion_raised=False
+        try:
+            self.assertNoBrokenLinks(response, internal_only=False)
+        except AssertionError, e:
+            assertion_raised = True # Should always get here
+            self.assertEqual(
+                str(e), 
+                "The link 'http://djangoproject.com/badlink.html' on line 11"
+                " appears to be broken."
+            )
+            
+        self.assertTrue(assertion_raised)
+
+    def test_invalid_external_link(self):
+        "Tests that assertion finds invalid external links"
+
+        response = self.client.get('/test_client_regress/invalid_external_link_view/')
+        self.assertEqual(response.status_code, 200)
+        
+        assertion_raised=False
+        try:
+            self.assertNoBrokenLinks(response, internal_only=False)
+        except AssertionError, e:
+            assertion_raised = True # Should always get here
+            self.assertEqual(
+                str(e), 
+                "The link 'http://djangoproject&.com' on line 4 appears"
+                " to be broken."
+            )
+            
+        self.assertTrue(assertion_raised)
+        
+    def test_broken_internal_link(self):
+        "Tests that assertion finds broken internal links"
+
+        response = self.client.get('/test_client_regress/broken_internal_link_view/')
+        self.assertEqual(response.status_code, 200)
+        
+        assertion_raised=False
+        try:
+            self.assertNoBrokenLinks(response)
+        except AssertionError, e:
+            assertion_raised = True # Should always get here
+            self.assertEqual(
+                str(e), 
+                "The link '/test_client_regress/broken_view/' on line 4"
+                " appears to be broken (status is 500)"
+            )
+            
+        self.assertTrue(assertion_raised)
+        
+    def test_bad_internal_link(self):
+        "Tests that assertion finds bad internal links"
+
+        response = self.client.get('/test_client_regress/bad_internal_link_view/')
+        self.assertEqual(response.status_code, 200)
+        
+        assertion_raised=False
+        try:
+            self.assertNoBrokenLinks(response)
+        except AssertionError, e:
+            assertion_raised = True # Should always get here
+            self.assertEqual(
+                str(e), 
+                "The link '/test_client_regress/bad_view/' on line 4 appears to"
+                " be broken (status is 404)"
+            )
+            
+        self.assertTrue(assertion_raised)
+
+    def test_blank_link(self):
+        "Tests that links with blank hrefs are identified appropriately"
+        
+        response = self.client.get('/test_client_regress/blank_link_view/')
+        self.assertEqual(response.status_code, 200)
+        
+        assertion_raised=False
+        try:
+            self.assertNoBrokenLinks(response)
+        except AssertionError, e:
+            assertion_raised = True # Should always get here
+            self.assertEqual(
+                str(e), 
+                "The page contains a link with an empty href on line 8."
+            )
+            
+        self.assertTrue(assertion_raised)
+    
+    def test_internal_page_link(self):
+        "Tests that internal page links are valid"
+        
+        response = self.client.get('/test_client_regress/internal_page_link_view/')
+        self.assertEqual(response.status_code, 200)
+        
+        assertion_raised=False
+        try:
+            self.assertNoBrokenLinks(response)
+        except AssertionError, e:
+            assertion_raised = True # Should always get here
+            self.assertEqual(
+                str(e), 
+                "The internal link to #footer on line 9 does not link to a"
+                " corresponding element with an id=\"footer\"."
+            )
+            
+        self.assertTrue(assertion_raised)
+        
 class FileUploadTests(TestCase):
     def test_simple_upload(self):
         fd = open(os.path.join(os.path.dirname(__file__), "views.py"))
Index: tests/regressiontests/test_client_regress/urls.py
===================================================================
--- tests/regressiontests/test_client_regress/urls.py	(revision 6352)
+++ tests/regressiontests/test_client_regress/urls.py	(working copy)
@@ -5,5 +5,15 @@
     (r'^no_template_view/$', views.no_template_view),
     (r'^file_upload/$', views.file_upload_view),
     (r'^get_view/$', views.get_view),
-    (r'^login_protected_redirect_view/$', views.login_protected_redirect_view)
+    (r'^login_protected_redirect_view/$', views.login_protected_redirect_view),
+    # All the following urls are for the assertNoBrokenLinks feature:
+    (r'^no_broken_links_view/$', views.no_broken_links_view),
+    (r'^broken_external_link_view/$', views.broken_external_link_view),
+    (r'^broken_internal_link_view/$', views.broken_internal_link_view),
+    (r'^bad_internal_link_view/$', views.bad_internal_link_view),
+    (r'^invalid_external_link_view/$', views.invalid_external_link_view),
+    (r'^blank_link_view/$', views.blank_link_view),
+    (r'^internal_page_link_view/$', views.internal_page_link_view),
+    (r'^broken_view/$', views.broken_view),
+    (r'^bad_view/$', views.bad_view)
 )
Index: AUTHORS
===================================================================
--- AUTHORS	(revision 6352)
+++ AUTHORS	(working copy)
@@ -210,6 +210,7 @@
     Jason McBrayer <http://www.carcosa.net/jason/>
     mccutchen@gmail.com
     michael.mcewan@gmail.com
+    Michael Nelson <http://liveandletlearn.net/>
     mikko@sorl.net
     Slawek Mikula <slawek dot mikula at gmail dot com>
     mitakummaa@gmail.com
Index: docs/testing.txt
===================================================================
--- docs/testing.txt	(revision 6352)
+++ docs/testing.txt	(working copy)
@@ -846,6 +846,22 @@
 
     The name is a string such as ``'admin/index.html'``.
 
+``assertNoBrokenLinks(response, internal_only=True)``
+    Asserts that all the anchor links within the response are not broken (ie.
+    result in a status of 200 or a redirect). By default only links internal to
+    the site will be checked (ie. those not beginning with http:// or https://).
+    
+    Internal page links such as <a href="#content"> are checked to ensure
+    that they are not broken (ie. that an element with the id="content" exists
+    on the page).
+    
+    Blank links, such as <a href=""> are also identified, which is helpful
+    to check when the url tag in <a href="{% url my-url-name arg1 %}"> fails.
+    
+    Note: As this assertion effectively clicks on all the links within the 
+    response, care needs to be taken if any link has a side effect (such as 
+    modifying your database).
+
 E-mail services
 ---------------
 
